Compare commits

..

16 Commits

Author SHA1 Message Date
Uruk
5e81576c68 feat(home): adds pull to refresh functionality
Adds pull-to-refresh functionality on the home screen.

This change enhances user experience by allowing users to
manually refresh the home screen content. It also ensures
that the loading indicator is correctly managed during
refresh operations, using a try-finally block.
2026-02-11 19:09:01 +01:00
Uruk
a16d73688f feat(home): refresh data on app foreground
adds a hook that refetches home data when the app
returns to the foreground or starts from a cold start.

this ensures the home screen always displays the
most up-to-date content.
2026-02-10 23:10:07 +01:00
Uruk
ac0d3fcb86 refactor(item): improves top people display logic
Improves the logic for displaying the top people in the item sections.

The previous implementation simply sliced the people array which
could result in duplicate people being displayed.

This change ensures that the top people displayed are unique by
filtering duplicates based on their id, limiting the number of
displayed people to three.
2026-02-10 19:13:44 +01:00
renovate[bot]
8a782fb0c9 chore(deps): Update dependency @react-native-community/cli to v20.1.1 (#1413)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:38 +01:00
renovate[bot]
7d0b6c37b8 chore(deps): Update dependency react-i18next to v16.5.4 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:01 +01:00
renovate[bot]
5443222e4e chore(deps): Update actions/cache action to v5.0.2 (#1397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:31 +01:00
renovate[bot]
a77e86be21 chore(deps): Update github/codeql-action action to v4.32.0 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:22 +01:00
renovate[bot]
9d82549cd6 chore(deps): Update dependency @tanstack/react-query to v5.90.20 (#1405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:12 +01:00
renovate[bot]
cb5cb8bff7 chore(deps): Update dependency lodash to v4.17.23 [SECURITY] (#1394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:02 +01:00
renovate[bot]
0b61fb7d98 chore(deps): Update peter-evans/create-pull-request action to v8.1.0 (#1401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:53 +01:00
renovate[bot]
f762b9017f chore(deps): Update crowdin/github-action action to v2.14.0 (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:43 +01:00
renovate[bot]
b063fd6405 chore(deps): Update oven-sh/setup-bun action to v2.1.2 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:35 +01:00
renovate[bot]
88ac94ebeb chore(deps): Update actions/checkout action to v6.0.2 (#1398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:24 +01:00
Fredrik Burmester
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-19 08:41:52 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
27 changed files with 1135 additions and 1679 deletions

View File

@@ -33,7 +33,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -41,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -116,7 +116,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -124,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -187,7 +187,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -195,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -251,7 +251,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -259,12 +259,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -24,16 +24,16 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0

View File

@@ -23,12 +23,12 @@ jobs:
steps: steps:
- name: 📥 Checkout Repository - name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
@@ -100,7 +100,7 @@ jobs:
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
@@ -112,7 +112,7 @@ jobs:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest

View File

@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

View File

@@ -41,15 +41,6 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="google-cast-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -449,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent; const { position, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
@@ -459,6 +459,12 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
// Update cache progress (current position + buffered seconds ahead)
if (cacheSeconds !== undefined && cacheSeconds > 0) {
const cacheEnd = currentTime + cacheSeconds * 1000;
cacheProgress.set(cacheEnd);
}
// Update URL immediately after seeking, or every 30 seconds during normal playback // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now(); const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get(); const shouldUpdateUrl = wasJustSeeking.get();

View File

@@ -1,184 +0,0 @@
import React, { useMemo, useState, useRef } from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Button } from "@/components/Button";
import { Feather } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import GoogleCast, {
CastButton,
CastContext,
CastState,
useCastDevice,
useCastState,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useRouter } from "expo-router";
import { useHaptic } from "@/hooks/useHaptic";
import ChromecastControls from "@/components/ChromecastControls";
import { useTranslation } from "react-i18next";
export default function Player() {
const castState = useCastState();
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
const reportPlaybackStopedRef = useRef(() => {});
useEffect(() => {
if (mediaStatus) return; // media currently playing
// media was just playing, report playback stopped
if (wasMediaPlaying) {
reportPlaybackStopedRef.current();
setWasMediaPlaying(false);
}
}, [mediaStatus, wasMediaPlaying]);
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const { t } = useTranslation();
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const GoHomeButton = useCallback(
() => (
<Button
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
{t("chromecast.go_home")}
</Button>
),
[router]
);
const ChromecastControlsMemoized = useMemo(() => {
if (!mediaStatus || !client) return undefined;
return (
<ChromecastControls
mediaStatus={mediaStatus}
client={client}
setWasMediaPlaying={setWasMediaPlaying}
reportPlaybackStopedRef={reportPlaybackStopedRef}
/>
);
}, [mediaStatus, client]);
if (
castState === CastState.NO_DEVICES_AVAILABLE ||
castState === CastState.NOT_CONNECTED
) {
// no devices to connect to
if (devices.length === 0) {
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_devices_available")}
</Text>
<Text className="text-gray-400">
{t("chromecast.are_you_on_same_network")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
// no device selected
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<RoundButton
size="large"
background={false}
onPress={() => {
lightHapticFeedback();
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={42} color={"white"} />
</RoundButton>
<Text className="text-white text-xl mt-2">
{t("chromecast.no_device_selected")}
</Text>
<Text className="text-gray-400">
{t("chromecast.click_icon_to_connect")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
if (castState === CastState.CONNECTING) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white font-semibold lg mb-2">
{t("chromecast.establishing_connection")}
</Text>
<Loader />
</View>
);
}
// connected, but no media playing
if (!mediaStatus) {
return (
<View className="w-screen h-screen flex flex-col ">
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_media_selected")}
</Text>
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
return ChromecastControlsMemoized;
}

View File

@@ -19,7 +19,7 @@
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1", "@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18", "@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "~54.0.31", "expo": "~54.0.31",
@@ -52,12 +52,12 @@
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"i18next": "^25.0.0", "i18next": "^25.0.0",
"jotai": "2.16.2", "jotai": "2.16.2",
"lodash": "4.17.21", "lodash": "4.17.23",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "16.5.3", "react-i18next": "16.5.4",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.1.0",
@@ -97,7 +97,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.6", "@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0", "@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.23",
@@ -512,29 +512,29 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="], "@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="], "@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="], "@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="], "@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="], "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="], "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="], "@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="], "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="], "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="], "@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="], "@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="], "@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
@@ -602,7 +602,7 @@
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="], "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="], "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
@@ -1376,7 +1376,7 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -1640,7 +1640,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="], "react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
@@ -1854,6 +1854,8 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -2242,7 +2244,7 @@
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="], "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],

View File

@@ -1,8 +1,5 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { useCallback, useEffect } from "react";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
@@ -13,9 +10,7 @@ import GoogleCast, {
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { useRouter } from "expo-router";
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
@@ -29,12 +24,6 @@ export function Chromecast({
const sessionManager = GoogleCast.getSessionManager(); const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0);
const router = useRouter();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -47,53 +36,6 @@ export function Chromecast({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [client, devices, castDevice, sessionManager, discoveryManager]);
// Report video progress to Jellyfin server
useEffect(() => {
if (
!api ||
!user?.Id ||
!mediaStatus ||
!mediaStatus.mediaInfo?.contentId
) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
// Report every 10 seconds
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = streamUrl.includes("m3u8");
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
@@ -124,7 +66,7 @@ export function Chromecast({
className='mr-2' className='mr-2'
background={false} background={false}
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player'); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
{...props} {...props}
@@ -138,7 +80,7 @@ export function Chromecast({
<RoundButton <RoundButton
size='large' size='large'
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player'); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
{...props} {...props}

View File

@@ -1,897 +0,0 @@
import React, { useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Feather, Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import {
CastButton,
CastContext,
MediaStatus,
RemoteMediaClient,
useStreamPosition,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Image } from "expo-image";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { debounce } from "lodash";
import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "@/hooks/useHaptic";
import { writeToLog } from "@/utils/log";
import { formatTimeString } from "@/utils/time";
import SkipButton from "@/components/video-player/controls/SkipButton";
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useTrickplay } from "@/hooks/useTrickplay";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
import { useAtomValue } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
import { SelectedOptions } from "./ItemContent";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { useQuery } from "@tanstack/react-query";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useTranslation } from "react-i18next";
import { Colors } from "@/constants/Colors";
import { useRouter } from "expo-router";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { ItemImage } from "@/components/common/ItemImage";
import { BitrateSelector } from "@/components/BitrateSelector";
import { ItemHeader } from "@/components/ItemHeader";
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
import { OverviewText } from "@/components/OverviewText";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PlayedStatus } from "./PlayedStatus";
import { AddToFavorites } from "./AddToFavorites";
export default function ChromecastControls({
mediaStatus,
client,
setWasMediaPlaying,
reportPlaybackStopedRef,
}: {
mediaStatus: MediaStatus;
client: RemoteMediaClient;
setWasMediaPlaying: (wasPlaying: boolean) => void;
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
}) {
const lightHapticFeedback = useHaptic("light");
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Infinity);
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
const streamPosition = useStreamPosition();
const progress = useSharedValue(streamPosition || 0);
const wasPlayingRef = useRef(false);
const isSeeking = useSharedValue(false);
const isPlaying = useMemo(
() => mediaStatus.playerState === "playing",
[mediaStatus.playerState]
);
const isBufferingOrLoading = useMemo(
() =>
mediaStatus.playerState === null ||
mediaStatus.playerState === "buffering" ||
mediaStatus.playerState === "loading",
[mediaStatus.playerState]
);
// request update of media status every player state change
useEffect(() => {
client.requestStatus();
}, [mediaStatus.playerState]);
// update max progress
useEffect(() => {
if (mediaStatus.mediaInfo?.streamDuration)
max.value = mediaStatus.mediaInfo?.streamDuration;
}, [mediaStatus.mediaInfo?.streamDuration]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
setCurrentTime(currentProgress);
setRemainingTime(maxValue - currentProgress);
},
[]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
const { mediaMetadata, itemId, streamURL } = useMemo(
() => ({
mediaMetadata: mediaStatus.mediaInfo?.metadata,
itemId: mediaStatus.mediaInfo?.contentId,
streamURL: mediaStatus.mediaInfo?.contentUrl,
}),
[mediaStatus]
);
const type = useMemo(
() => mediaMetadata?.type || "generic",
[mediaMetadata?.type]
);
const images = useMemo(
() => mediaMetadata?.images || [],
[mediaMetadata?.images]
);
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
const mediaCustomData = mediaStatus.mediaInfo?.customData as
| {
playbackOptions: SelectedOptions;
sessionId?: string;
mediaSourceId?: string;
}
| undefined;
return (
mediaCustomData || {
playbackOptions: undefined,
sessionId: undefined,
mediaSourceId: undefined,
}
);
}, [mediaStatus.mediaInfo?.customData]);
const {
data: item,
// currently nothing is indicating that item is loading, because most of the time it loads very fast
isLoading: isLoadingItem,
isError: isErrorItem,
error,
refetch,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!itemId) return;
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
});
const onProgress = useCallback(
async (progressInTicks: number, isPlaying: boolean) => {
if (!item?.Id || !streamURL) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
positionTicks: Math.floor(progressInTicks),
isPaused: !isPlaying,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
},
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
);
// update progess on stream position change
useEffect(() => {
if (streamPosition) {
progress.value = streamPosition;
onProgress(secondsToTicks(streamPosition), isPlaying);
}
}, [streamPosition, isPlaying]);
const reportPlaybackStart = useCallback(async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
// report playback started
useEffect(() => {
setWasMediaPlaying(true);
reportPlaybackStart();
}, [reportPlaybackStart]);
// update the reportPlaybackStoppedRef
useEffect(() => {
reportPlaybackStopedRef.current = async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId,
positionTicks: secondsToTicks(progress.value),
playSessionId: sessionId,
});
};
}, [
api,
item,
playbackOptions,
progress,
mediaSourceId,
streamURL,
sessionId,
]);
const { previousItem, nextItem } = useAdjacentItems({
item: {
Id: itemId,
SeriesId: item?.SeriesId,
Type: item?.Type,
},
});
const goToItem = useCallback(
async (item: BaseItemDto) => {
if (!api) {
console.warn("Failed to go to item: No api!");
return;
}
const previousIndexes: previousIndexes = {
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
audioIndex: playbackOptions?.audioIndex || undefined,
};
const {
mediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: defaultAudioIndex,
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
subtitleStreamIndex: defaultSubtitleIndex,
mediaSourceId: mediaSource?.Id,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert("Client error", "Could not create stream for Chromecast");
return;
}
await chromecastLoadMedia({
client,
item,
contentUrl: data.url,
sessionId: data.sessionId || undefined,
mediaSourceId: data.mediaSource?.Id || undefined,
playbackOptions,
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
});
await client.requestStatus();
},
[client, api]
);
const goToNextItem = useCallback(() => {
if (!nextItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(nextItem);
}, [nextItem, lightHapticFeedback]);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(previousItem);
}, [previousItem, lightHapticFeedback]);
const pause = useCallback(() => {
client.pause();
}, [client]);
const play = useCallback(() => {
client.play();
}, [client]);
const seek = useCallback(
(time: number) => {
// skip to next episode if seeking to end (for credit skipping)
// with 1 second room to react
if (nextItem && time >= max.value - 1) {
goToNextItem();
return;
}
client.seek({
position: time,
});
},
[client, goToNextItem, nextItem, max]
);
const togglePlay = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = Math.max(0, curr - settings.rewindSkipTime);
seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = curr + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const { showSkipButton, skipIntro } = useIntroSkipper(
itemId,
currentTime,
seek,
play,
false
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
itemId,
currentTime,
seek,
play,
false
);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const TrickplaySliderMemoized = useMemo(
() => (
<TrickplaySlider
item={item}
progress={progress}
wasPlayingRef={wasPlayingRef}
isPlaying={isPlaying}
isSeeking={isSeeking}
range={{ max }}
play={play}
pause={pause}
seek={seek}
/>
),
[
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
max,
play,
pause,
seek,
]
);
const NextEpisodeButtonMemoized = useMemo(
() => (
<NextEpisodeCountDownButton
show={nextItem !== null && max.value > 0 && remainingTime < 10}
onFinish={goToNextItem}
onPress={goToNextItem}
/>
),
[nextItem, max, remainingTime, goToNextItem]
);
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const logoUrl = useMemo(() => images[0]?.url, [images]);
if (isErrorItem) {
return (
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
<View className="p-12 flex gap-4">
<Text className="text-center font-semibold text-red-500 text-lg">
{t("chromecast.error_loading_item")}
</Text>
{error && (
<Text className="text-center opacity-80">{error.message}</Text>
)}
</View>
<View className="flex gap-2 mt-auto mb-20">
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => refetch()}
>
<Ionicons name="reload" size={24} color={Colors.primary} />
<Text className="ml-2 text-purple-600 text-lg">
{t("chromecast.retry_load_item")}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
<Ionicons name="home" size={16} color={Colors.text} />
<Text className="ml-2 text-white text-sm underline">
{t("chromecast.go_home")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
if (!item) {
return <Text>Do something when item is undefined</Text>;
}
if (!playbackOptions) {
return <Text>Do something when playbackOptions is undefined</Text>;
}
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{/* TODO do navigation header properly */}
<View
className="flex flex-row justify-between absolute w-full top-2 z-50"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<RoundButton size="large" icon="arrow-back" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<RoundButton
size="large"
onPress={() => {
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={24} color={"white"} />
</RoundButton>
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} />
</View>
)}
</View>
<ParallaxScrollView
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) =>
// setSelectedOptions(
// (prev) => prev && { ...prev, bitrate: val }
// )
console.log("new selected options", val)
}
selected={playbackOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={(val) =>
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// mediaSource: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={playbackOptions.mediaSource}
onChange={(val) => {
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// audioIndex: val,
// }
// );
console.log("new selected options", val);
}}
selected={playbackOptions.audioIndex}
/>
<SubtitleTrackSelector
source={playbackOptions.mediaSource}
onChange={(val) =>
// setSelectedOptions(
// (prev) =>
// prev && {
// ...prev,
// subtitleIndex: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.subtitleIndex}
/>
</View>
)}
</View>
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
</View>
</ParallaxScrollView>
<View className="pt-2">
{TrickplaySliderMemoized}
<View className="flex flex-row items-center justify-between mt-2">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, "s")}
</Text>
</View>
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
<Ionicons
name="play-skip-back-outline"
size={30}
color={previousItem ? "white" : "gray"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons name="play-back-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => togglePlay()}
className="flex w-14 h-14 items-center justify-center"
>
{!isBufferingOrLoading ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color="white"
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="play-forward-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
<Ionicons
name="play-skip-forward-outline"
size={30}
color={nextItem ? "white" : "gray"}
/>
</TouchableOpacity>
</View>
</View>
{/* TODO find proper placement for these buttons */}
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
{NextEpisodeButtonMemoized}
</View> */}
</View>
);
}
type TrickplaySliderProps = {
item?: BaseItemDto;
progress: SharedValue<number>;
wasPlayingRef: React.MutableRefObject<boolean>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
range: { min?: SharedValue<number>; max: SharedValue<number> };
play: () => void;
pause: () => void;
seek: (time: number) => void;
};
function TrickplaySlider({
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
range,
play,
pause,
seek,
}: TrickplaySliderProps) {
const [isSliding, setIsSliding] = useState(false);
const lastProgressRef = useRef<number>(0);
const min = useSharedValue(range.min?.value || 0);
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(
{
Id: item?.Id,
RunTimeTicks: secondsToTicks(progress.value),
Trickplay: item?.Trickplay,
},
true
);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const handleSliderStart = useCallback(() => {
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [isPlaying]);
const handleSliderComplete = useCallback(async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(value)));
if (wasPlayingRef.current === true) play();
}, []);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
calculateTrickplayUrl(secondsToTicks(value));
const progressInSeconds = Math.floor(value);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 3),
[]
);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
return (
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={range.max}
/>
);
}

View File

@@ -1,20 +1,14 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BottomSheetView } from "@gorhom/bottom-sheet";
import { useRouter } from "expo-router"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native"; import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaStreamType,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -29,16 +23,29 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import useRouter from "@/hooks/useAppRouter";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
colors?: ThemeColors;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -47,56 +54,60 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
...props colors,
}: Props) => { }: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation(); const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [colorAtom] = useAtom(itemThemeColorAtom); const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter(); const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom); const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(colorAtom); const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings(); const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
if (!bitrateValue) { if (settings.maxAutoPlayEpisodeCount.value !== -1) {
router.push(`/player/direct-player?${q}`); updateSettings({ autoPlayEpisodeCount: 0 });
return;
} }
router.push(`/player/transcoding-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router, isOffline],
); );
const onPress = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
return; return;
} }
@@ -116,65 +127,155 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { } else {
// Get a new URL with the Chromecast device profile: // Check if user wants H265 for Chromecast
const data = await getStreamUrl({ const enableH265 = settings.enableH265ForChromecast;
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) { // Validate required parameters before calling getStreamUrl
console.warn("No URL returned from getStreamUrl", data); if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.could_not_create_stream_for_chromecast") t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
); );
return; return;
} }
chromecastLoadMedia({ // Get a new URL with the Chromecast device profile
client, try {
item, const data = await getStreamUrl({
contentUrl: data.url, api,
sessionId: data.sessionId || undefined, item,
mediaSourceId: data.mediaSource?.Id || undefined, deviceProfile: enableH265 ? chromecasth265 : chromecast,
playbackOptions: selectedOptions, startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
images: [ userId: user.Id,
{ audioStreamIndex: selectedOptions.audioIndex,
url: getParentBackdropImageUrl({ maxStreamingBitrate: selectedOptions.bitrate?.value,
api, mediaSourceId: selectedOptions.mediaSource?.Id,
item, subtitleStreamIndex: selectedOptions.subtitleIndex,
quality: 90, });
width: 2000,
})!, console.log("URL: ", data?.url, enableH265);
},
], if (!data?.url) {
}).then(() => { console.warn("No URL returned from getStreamUrl", data);
// state is already set when reopening current media, so skip it here. Alert.alert(
if (isOpeningCurrentlyPlayingMedia) { t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return; return;
} }
router.push("/player/google-cast-player");
}); // Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
} }
}); });
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
} }
} },
); );
}, [ }, [
item, item,
@@ -186,16 +287,140 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData; const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) { if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH,
) )
: 0; : 0;
} }
@@ -212,11 +437,11 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item] [item],
); );
useAnimatedReaction( useAnimatedReaction(
() => colorAtom, () => effectiveColors,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -225,19 +450,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom] [effectiveColors],
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = colorAtom; startColor.value = effectiveColors;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [colorAtom, item]); }, [effectiveColors, item]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -246,7 +471,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -254,7 +479,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -262,7 +487,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value] [startWidth.value, targetWidth.value],
)}%`, )}%`,
})); }));
@@ -270,83 +495,61 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text] [startColor.value.text, endColor.value.text],
), ),
})); }));
/**
* *********************
*/
return ( return (
<View> <TouchableOpacity
<TouchableOpacity disabled={!item}
disabled={!item} accessibilityLabel='Play button'
accessibilityLabel="Play button" accessibilityHint='Tap to play the media'
accessibilityHint="Tap to play the media" onPress={onPress}
onPress={onPress} className={"relative flex-1"}
className={`relative`} >
{...props} <View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[
className="absolute w-full h-full top-0 left-0 rounded-xl" animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/> />
<View </View>
style={{
borderWidth: 1, <Animated.View
borderColor: colorAtom.primary, style={[animatedAverageStyle, { opacity: 0.5 }]}
borderStyle: "solid", className='absolute w-full h-full top-0 left-0 rounded-full'
}} />
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " <View
> style={{
<View className="flex flex-row items-center space-x-2"> borderWidth: 1,
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> borderColor: effectiveColors.primary,
{runtimeTicksToMinutes(item?.RunTimeTicks)} borderStyle: "solid",
</Animated.Text> }}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text> </Animated.Text>
{client && ( )}
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View> </View>
</TouchableOpacity> </View>
{/* <View className="mt-2 flex flex-row items-center"> </TouchableOpacity>
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
); );
}; };

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -107,6 +108,9 @@ export const Home = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -196,10 +200,13 @@ export const Home = () => {
const refetch = async () => { const refetch = async () => {
setLoading(true); setLoading(true);
setLoadedSections(new Set()); try {
await refreshStreamyfinPluginSettings(); setLoadedSections(new Set());
await invalidateCache(); await refreshStreamyfinPluginSettings();
setLoading(false); await invalidateCache();
} finally {
setLoading(false);
}
}; };
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
RefreshControl,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -37,6 +38,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,8 +69,8 @@ export const HomeWithCarousel = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings(); const { settings, refreshStreamyfinPluginSettings } = useSettings();
const [loading, setLoading] = useState(false);
const headerOverlayOffset = Platform.isTV ? 0 : 60; const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>(); const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -91,6 +93,19 @@ export const HomeWithCarousel = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const refetch = async () => {
setLoading(true);
try {
await refreshStreamyfinPluginSettings();
await invalidateCache();
} finally {
setLoading(false);
}
};
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -178,13 +193,6 @@ export const HomeWithCarousel = () => {
); );
}, [userViews]); }, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
title: string, title: string,
@@ -540,8 +548,18 @@ export const HomeWithCarousel = () => {
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16} scrollEventThrottle={16}
bounces={false} bounces={!Platform.isTV}
overScrollMode='never' overScrollMode='never'
refreshControl={
!Platform.isTV ? (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
) : undefined
}
style={{ marginTop: -headerOverlayOffset }} style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => { onScroll={(event) => {
@@ -570,7 +588,7 @@ export const HomeWithCarousel = () => {
settings.streamyStatsPromotedWatchlists; settings.streamyStatsPromotedWatchlists;
const streamystatsSections = const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? ( index === streamystatsIndex && hasStreamystatsContent ? (
<> <View key='streamystats-sections'>
{settings.streamyStatsMovieRecommendations && ( {settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
title={t( title={t(
@@ -590,7 +608,7 @@ export const HomeWithCarousel = () => {
{settings.streamyStatsPromotedWatchlists && ( {settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists /> <StreamystatsPromotedWatchlists />
)} )}
</> </View>
) : null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -37,7 +37,18 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto; return { ...item, People: people } as BaseItemDto;
}, [item, people]); }, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]); const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (person.Id && !seen.has(person.Id)) {
seen.add(person.Id);
unique.push(person);
}
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback( const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => { (person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -1,64 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps {
item?: BaseItemDto | null;
}
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom);
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!api || !item || !item.SeriesId) {
return null;
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
adjacentTo: item.Id,
limit: 3,
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
return res.data.Items || null;
},
enabled:
!!api &&
!!item?.Id &&
!!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"),
staleTime: 0,
});
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
return { previousItem, nextItem };
};

View File

@@ -0,0 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState } from "react-native";
/**
* Refetches active home queries on mount (cold start) and whenever
* the app returns to the foreground from background.
*/
export function useRefetchHomeOnForeground() {
const queryClient = useQueryClient();
useEffect(() => {
// On mount (cold start), use cancelRefetch: false so we don't cancel
// an in-flight initial fetch that React Query already started.
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: false },
);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
// On foreground return, force a fresh refetch
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: true },
);
}
});
return () => subscription.remove();
}, [queryClient]);
}

Binary file not shown.

View File

@@ -1,10 +1,13 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.content.res.AssetManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File
import java.io.FileOutputStream
/** /**
* MPV renderer that wraps libmpv for video playback. * MPV renderer that wraps libmpv for video playback.
@@ -26,7 +29,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
interface Delegate { interface Delegate {
fun onPositionChanged(position: Double, duration: Double) fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean) fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean) fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek() fun onReadyToSeek()
@@ -46,6 +49,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// Cached state // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
private var cachedCacheSeconds: Double = 0.0
private var _isPaused: Boolean = true private var _isPaused: Boolean = true
private var _isLoading: Boolean = false private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
@@ -101,6 +105,52 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.create(context) MPVLib.create(context)
MPVLib.addObserver(this) MPVLib.addObserver(this)
/**
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
*
* Technical Background:
* ====================
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
* even when subtitle tracks are properly detected and loaded.
*
* Why This Is Necessary:
* =====================
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
* mpv cannot access them directly due to sandboxing and library isolation.
*
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
* configured directory, mpv either:
* - Fails silently (subtitles don't appear)
* - Falls back to a default font that may not support the required character set
* - Crashes or produces rendering errors
*
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
*
* Reference:
* =========
* This workaround is documented in the mpv-android project:
* https://github.com/mpv-android/mpv-android/issues/96
*
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
*/
// Create mpv config directory and copy font files
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
arrayOf("subfont.ttf").forEach { fileName ->
val file = File(mpvDir, fileName)
if (file.exists()) return@forEach
context.assets
.open(fileName, AssetManager.ACCESS_STREAMING)
.copyTo(FileOutputStream(file))
}
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid) // Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
@@ -124,7 +174,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes") MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "yes") MPVLib.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes") MPVLib.setOptionString("subs-fallback", "yes")
@@ -283,6 +333,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
@@ -561,7 +612,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
when (property) { when (property) {
"duration" -> { "duration" -> {
cachedDuration = value cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
} }
"time-pos" -> { "time-pos" -> {
cachedPosition = value cachedPosition = value
@@ -570,9 +621,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) { if (shouldUpdate) {
lastProgressUpdateTime = now lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
} }
} }
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
}
} }
} }

View File

@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double) { override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -319,7 +319,8 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0 "progress" to if (duration > 0) position / duration else 0.0,
"cacheSeconds" to cacheSeconds
)) ))
} }

View File

@@ -5,7 +5,7 @@ import CoreVideo
import AVFoundation import AVFoundation
protocol MPVLayerRendererDelegate: AnyObject { protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double)
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
@@ -44,6 +44,7 @@ final class MPVLayerRenderer {
// Thread-safe state for playback // Thread-safe state for playback
private var _cachedDuration: Double = 0 private var _cachedDuration: Double = 0
private var _cachedPosition: Double = 0 private var _cachedPosition: Double = 0
private var _cachedCacheSeconds: Double = 0
private var _isPaused: Bool = true private var _isPaused: Bool = true
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
private var _isLoading: Bool = false private var _isLoading: Bool = false
@@ -75,6 +76,10 @@ final class MPVLayerRenderer {
get { stateQueue.sync { _cachedPosition } } get { stateQueue.sync { _cachedPosition } }
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
} }
private var cachedCacheSeconds: Double {
get { stateQueue.sync { _cachedCacheSeconds } }
set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } }
}
private var isPaused: Bool { private var isPaused: Bool {
get { stateQueue.sync { _isPaused } } get { stateQueue.sync { _isPaused } }
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
@@ -164,6 +169,7 @@ final class MPVLayerRenderer {
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU // Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video // This is better for PiP as subtitles are baked into the video
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
// Hardware decoding with VideoToolbox // Hardware decoding with VideoToolbox
@@ -340,7 +346,8 @@ final class MPVLayerRenderer {
("time-pos", MPV_FORMAT_DOUBLE), ("time-pos", MPV_FORMAT_DOUBLE),
("pause", MPV_FORMAT_FLAG), ("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64), ("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG) ("paused-for-cache", MPV_FORMAT_FLAG),
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
] ]
for (name, format) in properties { for (name, format) in properties {
mpv_observe_property(handle, 0, name, format) mpv_observe_property(handle, 0, name, format)
@@ -484,7 +491,7 @@ final class MPVLayerRenderer {
cachedDuration = value cachedDuration = value
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
} }
} }
case "time-pos": case "time-pos":
@@ -499,10 +506,16 @@ final class MPVLayerRenderer {
lastProgressUpdateTime = now lastProgressUpdateTime = now
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
} }
} }
} }
case "demuxer-cache-duration":
var value = Double(0)
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
if status >= 0 {
cachedCacheSeconds = value
}
case "pause": case "pause":
var flag: Int32 = 0 var flag: Int32 = 0
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)

View File

@@ -298,7 +298,7 @@ class MpvPlayerView: ExpoView {
// MARK: - MPVLayerRendererDelegate // MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate { extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) { func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -313,6 +313,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
"position": position, "position": position,
"duration": duration, "duration": duration,
"progress": duration > 0 ? position / duration : 0, "progress": duration > 0 ? position / duration : 0,
"cacheSeconds": cacheSeconds,
]) ])
} }
} }

View File

@@ -15,6 +15,8 @@ export type OnProgressEventPayload = {
position: number; position: number;
duration: number; duration: number;
progress: number; progress: number;
/** Seconds of video buffered ahead of current position */
cacheSeconds: number;
}; };
export type OnErrorEventPayload = { export type OnErrorEventPayload = {

View File

@@ -39,7 +39,7 @@
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1", "@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18", "@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "~54.0.31", "expo": "~54.0.31",
@@ -72,12 +72,12 @@
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"i18next": "^25.0.0", "i18next": "^25.0.0",
"jotai": "2.16.2", "jotai": "2.16.2",
"lodash": "4.17.21", "lodash": "4.17.23",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "16.5.3", "react-i18next": "16.5.4",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.1.0",
@@ -117,7 +117,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.6", "@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0", "@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.23",

View File

@@ -1,67 +1,156 @@
{ {
"login": { "login": {
"username_required": "Username is required", "username_required": "Username Is Required",
"error_title": "Error", "error_title": "Error",
"login_title": "Log in", "login_title": "Log In",
"login_to_title": "Log in to", "login_to_title": "Log in to",
"username_placeholder": "Username", "username_placeholder": "Username",
"password_placeholder": "Password", "password_placeholder": "Password",
"login_button": "Log in", "login_button": "Log In",
"quick_connect": "Quick Connect", "quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login", "enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got it", "got_it": "Got It",
"connection_failed": "Connection failed", "connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An unexpected error occurred", "an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change server", "change_server": "Change Server",
"invalid_username_or_password": "Invalid username or password", "invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in", "user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", "server_is_taking_too_long_to_respond_try_again_later": "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": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error", "there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?" "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
}, },
"server": { "server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com", "server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect", "connect_button": "Connect",
"previous_servers": "previous servers", "previous_servers": "Previous Servers",
"clear_button": "Clear", "clear_button": "Clear all",
"search_for_local_servers": "Search for local servers", "swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...", "searching": "Searching...",
"servers": "Servers" "servers": "Servers",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
}, },
"home": { "home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet", "no_internet": "No Internet",
"no_items": "No items", "no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.", "no_internet_message": "No worries, you can still watch\ndownloaded content.",
"go_to_downloads": "Go to downloads", "checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!", "oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.", "error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching", "continue_watching": "Continue Watching",
"next_up": "Next Up", "next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}", "recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies", "suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes", "suggested_episodes": "Suggested Episodes",
"intro": { "intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin", "welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.", "a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features", "features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads", "downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", "downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done", "done_button": "Done",
"go_to_settings_button": "Go to settings", "go_to_settings_button": "Go to Settings",
"read_more": "Read more" "read_more": "Read More"
}, },
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "Settings",
"log_out_button": "Log out", "log_out_button": "Log Out",
"categories": {
"title": "Categories"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
},
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
},
"user_info": { "user_info": {
"user_info_title": "User Info", "user_info_title": "User Info",
"user": "User", "user": "User",
@@ -74,32 +163,53 @@
"authorize_button": "Authorize Quick Connect", "authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...", "enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success", "success": "Success",
"quick_connect_autorized": "Quick Connect authorized", "quick_connect_autorized": "Quick Connect Authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid Code",
"authorize": "Authorize" "authorize": "Authorize"
}, },
"media_controls": { "media_controls": {
"media_controls_title": "Media Controls", "media_controls_title": "Media Controls",
"forward_skip_length": "Forward skip length", "forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind length", "rewind_length": "Rewind Length",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": { "audio": {
"audio_title": "Audio", "audio_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item", "set_audio_track": "Set Audio Track From Previous Item",
"audio_language": "Audio language", "audio_language": "Audio Language",
"audio_hint": "Choose a default audio language.", "audio_hint": "Choose a default audio language.",
"none": "None", "none": "None",
"language": "Language" "language": "Language",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
}
}, },
"subtitles": { "subtitles": {
"subtitle_title": "Subtitles", "subtitle_title": "Subtitles",
"subtitle_hint": "Configure how subtitles look and behave.",
"subtitle_language": "Subtitle language", "subtitle_language": "Subtitle language",
"subtitle_mode": "Subtitle Mode", "subtitle_mode": "Subtitle Mode",
"set_subtitle_track": "Set Subtitle Track From Previous Item", "set_subtitle_track": "Set Subtitle Track From Previous Item",
"subtitle_size": "Subtitle Size", "subtitle_size": "Subtitle Size",
"subtitle_hint": "Configure subtitle preference.",
"none": "None", "none": "None",
"language": "Language", "language": "Language",
"loading": "Loading", "loading": "Loading",
@@ -109,45 +219,110 @@
"Always": "Always", "Always": "Always",
"None": "None", "None": "None",
"OnlyForced": "OnlyForced" "OnlyForced": "OnlyForced"
} },
"text_color": "Text Color",
"background_color": "Background Color",
"outline_color": "Outline Color",
"outline_thickness": "Outline Thickness",
"background_opacity": "Background Opacity",
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"colors": {
"Black": "Black",
"Gray": "Gray",
"Silver": "Silver",
"White": "White",
"Maroon": "Maroon",
"Red": "Red",
"Fuchsia": "Fuchsia",
"Yellow": "Yellow",
"Olive": "Olive",
"Green": "Green",
"Teal": "Teal",
"Lime": "Lime",
"Purple": "Purple",
"Navy": "Navy",
"Blue": "Blue",
"Aqua": "Aqua"
},
"thickness": {
"None": "None",
"Thin": "Thin",
"Normal": "Normal",
"Thick": "Thick"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
}, },
"other": { "other": {
"other_title": "Other", "other_title": "Other",
"auto_rotate": "Auto rotate", "video_orientation": "Video Orientation",
"video_orientation": "Video orientation",
"orientation": "Orientation", "orientation": "Orientation",
"orientations": { "orientations": {
"DEFAULT": "Default", "DEFAULT": "Follow Device Orientation",
"ALL": "All", "ALL": "All",
"PORTRAIT": "Portrait", "PORTRAIT": "Portrait Auto",
"PORTRAIT_UP": "Portrait Up", "PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down", "PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape", "LANDSCAPE": "Landscape Auto",
"LANDSCAPE_LEFT": "Landscape Left", "LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right", "LANDSCAPE_RIGHT": "Landscape Right",
"OTHER": "Other", "OTHER": "Other",
"UNKNOWN": "Unknown" "UNKNOWN": "Unknown"
}, },
"safe_area_in_controls": "Safe area in controls", "safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links", "show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries", "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.", "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" "disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"disabled": "Disabled"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads"
"download_method": "Download method", },
"remux_max_download": "Remux max download", "music": {
"auto_download": "Auto download", "title": "Music",
"optimized_versions_server": "Optimized versions server", "playback_title": "Playback",
"save_button": "Save", "playback_description": "Configure how music is played.",
"optimized_server": "Optimized Server", "prefer_downloaded": "Prefer Downloaded Songs",
"optimized": "Optimized", "caching_title": "Caching",
"default": "Default", "caching_description": "Automatically cache upcoming tracks for smoother playback.",
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.", "lookahead_enabled": "Enable Look-Ahead Caching",
"read_more_about_optimized_server": "Read more about the optimize server.", "lookahead_count": "Tracks to Pre-cache",
"url": "URL", "max_cache_size": "Max Cache Size"
"server_url_placeholder": "http(s)://domain.org:port"
}, },
"plugins": { "plugins": {
"plugins_title": "Plugins", "plugins_title": "Plugins",
@@ -155,124 +330,201 @@
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.", "jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL", "server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Jellyseerr URL...", "server_url_placeholder": "Seerr URL",
"password": "Password", "password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}", "password_placeholder": "Enter password for Jellyfin user {{username}}",
"save_button": "Save",
"clear_button": "Clear",
"login_button": "Login", "login_button": "Login",
"total_media_requests": "Total media requests", "total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie quota limit", "movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie quota days", "movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV quota limit", "tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV quota days", "tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config", "reset_jellyseerr_config_button": "Reset Seerr Config",
"unlimited": "Unlimited" "unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"POPULARITY": "Popularity"
}
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Enable Marlin Search ", "enable_marlin_search": "Enable Marlin Search",
"url": "URL", "url": "URL",
"server_url_placeholder": "http(s)://domain.org:port", "server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.", "marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read more about Marlin.", "read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save", "save_button": "Save",
"toasts": { "toasts": {
"saved": "Saved" "saved": "Saved",
} "refreshed": "Settings refreshed from server"
},
"refresh_from_server": "Refresh Settings from Server"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "Refresh Settings from Server"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
} }
}, },
"storage": { "storage": {
"storage_title": "Storage", "storage_title": "Storage",
"app_usage": "App {{usedSpace}}%", "app_usage": "App {{usedSpace}}%",
"phone_usage": "Phone {{availableSpace}}%", "device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used", "size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files" "delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
}, },
"intro": { "intro": {
"show_intro": "Show intro", "title": "Intro",
"reset_intro": "Reset intro" "show_intro": "Show Intro",
"reset_intro": "Reset Intro"
}, },
"logs": { "logs": {
"logs_title": "Logs", "logs_title": "Logs",
"no_logs_available": "No logs available", "export_logs": "Export Logs",
"delete_all_logs": "Delete all logs" "click_for_more_info": "Click for More Info",
"level": "Level",
"no_logs_available": "No Logs Available",
"delete_all_logs": "Delete All Logs"
}, },
"languages": { "languages": {
"title": "Languages", "title": "Languages",
"app_language": "App language", "app_language": "App Language",
"app_language_description": "Select the language for the app.",
"system": "System" "system": "System"
}, },
"toasts": { "toasts": {
"error_deleting_files": "Error deleting files", "error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled", "background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled", "background_downloads_disabled": "Background downloads disabled"
"connected": "Connected",
"could_not_connect": "Could not connect",
"invalid_url": "Invalid URL"
} }
}, },
"sessions": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
},
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
"tvseries": "TV-Series", "tvseries": "TV-Series",
"movies": "Movies", "movies": "Movies",
"queue": "Queue", "queue": "Queue",
"other_media": "Other media",
"queue_hint": "Queue and downloads will be lost on app restart", "queue_hint": "Queue and downloads will be lost on app restart",
"no_items_in_queue": "No items in queue", "no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No downloaded items", "no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete all Movies", "delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete all TV-Series", "delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete all", "delete_all_button": "Delete All",
"active_download": "Active download", "delete_all_other_media_button": "Delete other media",
"no_active_downloads": "No active downloads", "active_download": "Active Download",
"active_downloads": "Active downloads", "no_active_downloads": "No Active Downloads",
"active_downloads": "Active Downloads",
"new_app_version_requires_re_download": "New app version requires re-download", "new_app_version_requires_re_download": "New app version requires re-download",
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back", "back": "Back",
"delete": "Delete", "delete": "Delete",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
"methods": "Methods",
"toasts": { "toasts": {
"you_are_not_allowed_to_download_files": "You are not allowed to download files.", "you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted all movies successfully!", "deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to delete all movies", "failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!", "deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series", "failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"download_cancelled": "Download cancelled", "deleted_media_successfully": "Deleted other media Successfully!",
"could_not_cancel_download": "Could not cancel download", "failed_to_delete_media": "Failed to Delete other media",
"download_completed": "Download completed", "download_deleted": "Download Deleted",
"download_started_for": "Download started for {{item}}", "download_cancelled": "Download Cancelled",
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded", "could_not_delete_download": "Could Not Delete Download",
"download_stated_for_item": "Download started for {{item}}", "download_paused": "Download Paused",
"could_not_pause_download": "Could Not Pause Download",
"download_resumed": "Download Resumed",
"could_not_resume_download": "Could Not Resume Download",
"download_completed": "Download Completed",
"download_failed": "Download Failed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}", "download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download completed for {{item}}", "download_completed_for_item": "Download Completed for {{item}}",
"queued_item_for_optimization": "Queued {{item}} for optimization", "download_started_for_item": "Download Started for {{item}}",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}", "failed_to_start_download": "Failed to start download",
"server_responded_with_status_code": "Server responded with status {{statusCode}}", "item_already_downloading": "{{item}} is already downloading",
"no_response_received_from_server": "No response received from the server", "all_files_deleted": "All Downloads Deleted Successfully",
"error_setting_up_the_request": "Error setting up the request", "files_deleted_by_type": "{{count}} {{type}} deleted",
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs", "failed_to_clean_cache_directory": "Failed to clean cache directory",
"go_to_downloads": "Go to downloads" "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads",
"file_deleted": "{{item}} deleted"
} }
} }
}, },
"common": {
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
},
"search": { "search": {
"search_here": "Search here...",
"search": "Search...", "search": "Search...",
"x_items": "{{count}} items", "x_items": "{{count}} Items",
"library": "Library", "library": "Library",
"discover": "Discover", "discover": "Discover",
"no_results": "No results", "no_results": "No Results",
"no_results_found_for": "No results found for", "no_results_found_for": "No Results Found For",
"movies": "Movies", "movies": "Movies",
"series": "Series", "series": "Series",
"episodes": "Episodes", "episodes": "Episodes",
"collections": "Collections", "collections": "Collections",
"actors": "Actors", "actors": "Actors",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "Request Movies", "request_movies": "Request Movies",
"request_series": "Request Series", "request_series": "Request Series",
"recently_added": "Recently Added", "recently_added": "Recently Added",
@@ -298,29 +550,29 @@
"tmdb_tv_streaming_services": "TMDB TV Streaming Services" "tmdb_tv_streaming_services": "TMDB TV Streaming Services"
}, },
"library": { "library": {
"no_items_found": "No items found", "no_results": "No Results",
"no_results": "No results", "no_libraries_found": "No Libraries Found",
"no_libraries_found": "No libraries found",
"item_types": { "item_types": {
"movies": "movies", "movies": "Movies",
"series": "series", "series": "Series",
"boxsets": "box sets", "boxsets": "Box Sets",
"items": "items" "items": "Items"
}, },
"options": { "options": {
"display": "Display", "display": "Display",
"row": "Row", "row": "Row",
"list": "List", "list": "List",
"image_style": "Image style", "image_style": "Image Style",
"poster": "Poster", "poster": "Poster",
"cover": "Cover", "cover": "Cover",
"show_titles": "Show titles", "show_titles": "Show Titles",
"show_stats": "Show stats" "show_stats": "Show Stats"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
"years": "Years", "years": "Years",
"sort_by": "Sort By", "sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order", "sort_order": "Sort Order",
"tags": "Tags" "tags": "Tags"
} }
@@ -330,32 +582,37 @@
"movies": "Movies", "movies": "Movies",
"episodes": "Episodes", "episodes": "Episodes",
"videos": "Videos", "videos": "Videos",
"boxsets": "Boxsets", "boxsets": "Box Sets",
"playlists": "Playlists" "playlists": "Playlists",
"noDataTitle": "No Favorites Yet",
"noData": "Mark items as favorites to see them appear here for quick access."
}, },
"custom_links": { "custom_links": {
"no_links": "No links" "no_links": "No Links"
}, },
"player": { "player": {
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error", "client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}", "message_from_server": "Message from Server: {{message}}",
"video_has_finished_playing": "Video has finished playing!",
"no_video_source": "No video source...",
"next_episode": "Next Episode", "next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks", "refresh_tracks": "Refresh Tracks",
"subtitle_tracks": "Subtitle Tracks:",
"audio_tracks": "Audio Tracks:", "audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:", "playback_state": "Playback State:",
"no_data_available": "No data available", "index": "Index:",
"index": "Index:" "continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
}, },
"item_card": { "item_card": {
"next_up": "Next up", "next_up": "Next Up",
"no_items_to_display": "No items to display", "no_items_to_display": "No Items to Display",
"cast_and_crew": "Cast & Crew", "cast_and_crew": "Cast & Crew",
"series": "Series", "series": "Series",
"seasons": "Seasons", "seasons": "Seasons",
@@ -363,35 +620,34 @@
"no_episodes_for_this_season": "No episodes for this season", "no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview", "overview": "Overview",
"more_with": "More with {{name}}", "more_with": "More with {{name}}",
"similar_items": "Similar items", "similar_items": "Similar Items",
"no_similar_items_found": "No similar items found", "no_similar_items_found": "No Similar Items Found",
"video": "Video", "video": "Video",
"more_details": "More details", "more_details": "More Details",
"media_options": "Media Options",
"quality": "Quality", "quality": "Quality",
"audio": "Audio", "audio": "Audio",
"subtitles": "Subtitle", "subtitles": "Subtitle",
"show_more": "Show more", "show_more": "Show More",
"show_less": "Show less", "show_less": "Show Less",
"appeared_in": "Appeared in", "appeared_in": "Appeared In",
"could_not_load_item": "Could not load item", "could_not_load_item": "Could Not Load Item",
"none": "None", "none": "None",
"download": { "download": {
"download_season": "Download Season", "download_season": "Download Season",
"download_series": "Download Series", "download_series": "Download Series",
"download_episode": "Download Episode", "download_episode": "Download Episode",
"download_movie": "Download Movie", "download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} items", "download_x_item": "Download {{item_count}} Items",
"download_button": "Download", "download_unwatched_only": "Unwatched Only",
"using_optimized_server": "Using optimized server", "download_button": "Download"
"using_default_method": "Using default method"
} }
}, },
"live_tv": { "live_tv": {
"next": "Next", "next": "Next",
"previous": "Previous", "previous": "Previous",
"live_tv": "Live TV", "coming_soon": "Coming Soon",
"coming_soon": "Coming soon", "on_now": "On Now",
"on_now": "On now",
"shows": "Shows", "shows": "Shows",
"movies": "Movies", "movies": "Movies",
"sports": "Sports", "sports": "Sports",
@@ -402,16 +658,16 @@
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"yes": "Yes", "yes": "Yes",
"whats_wrong": "What's wrong?", "whats_wrong": "What's Wrong?",
"issue_type": "Issue type", "issue_type": "Issue Type",
"select_an_issue": "Select an issue", "select_an_issue": "Select an Issue",
"types": "Types", "types": "Types",
"describe_the_issue": "(optional) Describe the issue...", "describe_the_issue": "(Optional) Describe the Issue...",
"submit_button": "Submit", "submit_button": "Submit",
"report_issue_button": "Report issue", "report_issue_button": "Report Issue",
"request_button": "Request", "request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to login", "failed_to_login": "Failed to Login",
"cast": "Cast", "cast": "Cast",
"details": "Details", "details": "Details",
"status": "Status", "status": "Status",
@@ -426,25 +682,33 @@
"production_country": "Production Country", "production_country": "Production Country",
"studios": "Studios", "studios": "Studios",
"network": "Network", "network": "Network",
"currently_streaming_on": "Currently Streaming on", "currently_streaming_on": "Currently Streaming On",
"advanced": "Advanced", "advanced": "Advanced",
"request_as": "Request As", "request_as": "Request As",
"tags": "Tags", "tags": "Tags",
"quality_profile": "Quality Profile", "quality_profile": "Quality Profile",
"root_folder": "Root Folder", "root_folder": "Root Folder",
"season_x": "Season {{seasons}}", "season_all": "Season (All)",
"season_number": "Season {{season_number}}", "season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes", "number_episodes": "{{episode_number}} Episodes",
"born": "Born", "born": "Born",
"appearances": "Appearances", "appearances": "Appearances",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.", "jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url", "failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue submitted!", "issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!", "requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!", "you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!" "something_went_wrong_requesting_media": "Something went wrong requesting media!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
} }
}, },
"tabs": { "tabs": {
@@ -454,14 +718,128 @@
"custom_links": "Custom Links", "custom_links": "Custom Links",
"favorites": "Favorites" "favorites": "Favorites"
}, },
"chromecast": { "music": {
"no_devices_available": "No Google Cast devices available.", "title": "Music",
"are_you_on_same_network": "Are you on the same network?", "tabs": {
"no_device_selected": "No device selected", "suggestions": "Suggestions",
"click_icon_to_connect": "Click icon to connect.", "albums": "Albums",
"establishing_connection": "Establishing connection...", "artists": "Artists",
"no_media_selected": "No media selected.", "playlists": "Playlists",
"start_playing": "Start playing any media", "tracks": "tracks"
"go_home": "Go Home" },
"filters": {
"all": "All"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
"go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites",
"remove_from_favorites": "Remove from Favorites",
"remove_from_playlist": "Remove from Playlist"
},
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
} }
} }

View File

@@ -1,60 +0,0 @@
import { SelectedOptions } from "@/components/ItemContent";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { RemoteMediaClient, WebImage } from "react-native-google-cast";
import { ticksToSeconds } from "./time";
export function chromecastLoadMedia({
client,
item,
contentUrl,
sessionId,
mediaSourceId,
images,
playbackOptions,
}: {
client: RemoteMediaClient;
item: BaseItemDto;
contentUrl: string;
sessionId?: string;
mediaSourceId?: string;
images: WebImage[];
playbackOptions: SelectedOptions;
}) {
return client.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl,
contentType: "video/mp4",
customData: {
item,
playbackOptions,
sessionId,
mediaSourceId,
},
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images,
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images,
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images,
},
},
startTime: ticksToSeconds(item.UserData?.PlaybackPositionTicks) || 0,
});
}