diff --git a/.eas/build/android-production-apk.yml b/.eas/build/android-production-apk.yml
new file mode 100644
index 00000000..757192bb
--- /dev/null
+++ b/.eas/build/android-production-apk.yml
@@ -0,0 +1,25 @@
+# Custom EAS Build config for Android phone APK (downloadable artifact).
+# Same bun-forcing flow as android-production.yml, but builds an APK
+# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
+# Referenced from eas.json: build.production-apk.android.config
+build:
+ name: Android phone APK (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ - run:
+ name: Prebuild (Android, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ - eas/configure_android_version
+ - eas/inject_android_credentials
+
+ - eas/run_gradle:
+ inputs:
+ command: :app:assembleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/android-production-tv.yml b/.eas/build/android-production-tv.yml
new file mode 100644
index 00000000..a3fc9eff
--- /dev/null
+++ b/.eas/build/android-production-tv.yml
@@ -0,0 +1,27 @@
+# Custom EAS Build config for Android TV APK (downloadable artifact).
+# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
+# eas.json) so prebuild generates the TV variant. Builds an APK for
+# sideloading onto Android TV devices.
+# Referenced from eas.json: build.production-apk-tv.android.config
+build:
+ name: Android TV APK (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ # EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
+ - run:
+ name: Prebuild (Android TV, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ - eas/configure_android_version
+ - eas/inject_android_credentials
+
+ - eas/run_gradle:
+ inputs:
+ command: :app:assembleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/android-production.yml b/.eas/build/android-production.yml
new file mode 100644
index 00000000..651ff2b6
--- /dev/null
+++ b/.eas/build/android-production.yml
@@ -0,0 +1,38 @@
+# Custom EAS Build config for Android (production AAB).
+#
+# Why this exists: EAS's managed build can't detect Bun's text lockfile
+# (bun.lock) and falls back to yarn, which breaks our install. The managed
+# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
+# manager detected based on your project", so we replace them with explicit
+# `bun` commands. Everything else uses EAS's built-in functions so we still
+# get remote versioning, credentials, and artifact upload.
+#
+# Referenced from eas.json: build.production.android.config = android-production.yml
+build:
+ name: Android production (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ # android/ is gitignored, so generate native code fresh. --no-install
+ # because deps are already installed above; bunx keeps it on bun.
+ - run:
+ name: Prebuild (Android, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ # Applies the EAS-resolved remote versionCode/versionName (autoIncrement
+ # in eas.json) into the freshly prebuilt android/ project.
+ - eas/configure_android_version
+
+ # Injects the remote Android keystore / signing config.
+ - eas/inject_android_credentials
+
+ # Build the Play Store app bundle (.aab).
+ - eas/run_gradle:
+ inputs:
+ command: :app:bundleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/ios-production.yml b/.eas/build/ios-production.yml
new file mode 100644
index 00000000..f9e228f7
--- /dev/null
+++ b/.eas/build/ios-production.yml
@@ -0,0 +1,44 @@
+# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
+#
+# Shared by both the iPhone profile (production) and the tvOS profile
+# (production_tv). The profile decides the rest:
+# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
+# credentialsSource: local (EAS can't manage tvOS creds remotely).
+# - production uses remote-managed iOS credentials.
+#
+# Like the Android configs, this replaces eas/install_node_modules and
+# eas/prebuild (both auto-detect the wrong package manager) with explicit
+# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
+build:
+ name: iOS/tvOS App Store (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ - eas/resolve_apple_team_id_from_credentials:
+ id: resolve_team
+
+ # android/ + ios/ are gitignored, so generate native code fresh.
+ # EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
+ # skips JS + pod install; we install pods explicitly below with bun deps.
+ - run:
+ name: Prebuild (iOS/tvOS, bun)
+ command: bunx expo prebuild --platform ios --no-install
+
+ - run:
+ name: Install CocoaPods
+ working_directory: ./ios
+ command: pod install
+
+ - eas/configure_ios_credentials
+ - eas/configure_ios_version
+
+ - eas/generate_gymfile_from_template:
+ inputs:
+ credentials: ${ eas.job.secrets.buildCredentials }
+
+ - eas/run_fastlane
+ - eas/find_and_upload_build_artifacts
diff --git a/.gitattributes b/.gitattributes
index 56dea966..4d651aeb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,28 @@
-.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
+# Normalise line endings to LF for everyone. Files are stored as LF in git and
+# checked out as LF on every OS, so Windows clones stop producing CRLF churn
+# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
+* text=auto eol=lf
+
+# Windows-only scripts must stay CRLF
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Binary assets — never touched / never normalised
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.webp binary
+*.ico binary
+*.icns binary
+*.ttf binary
+*.otf binary
+*.woff binary
+*.woff2 binary
+*.mp3 binary
+*.mp4 binary
+*.mov binary
+*.pdf binary
+*.keystore binary
+*.jks binary
+*.p12 binary
diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml
index ccdb0d0e..af86644d 100644
--- a/.github/ISSUE_TEMPLATE/issue_report.yml
+++ b/.github/ISSUE_TEMPLATE/issue_report.yml
@@ -1,5 +1,5 @@
name: "🐛 Bug Report"
-description: Create a report to help us improve
+description: Create a report to help Streamyfin improve
title: "[Bug]: "
labels:
- "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes:
label: What happened?
description: A clear and concise description of what the bug is.
- placeholder: Describe what happened in detail.
+ placeholder: Describe what happened in detail, the more precise the better.
validations:
required: true
@@ -67,7 +67,7 @@ body:
attributes:
label: Which device and operating system are you using?
description: Please provide your device model and OS version
- placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
+ placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
validations:
required: true
@@ -75,11 +75,11 @@ body:
id: version
attributes:
label: Streamyfin Version
- description: What version of Streamyfin are you running?
+ description: What version of Streamyfin are you using?
options:
- - 0.47.1
- - 0.30.2
- - older
+ - 0.54.1
+ - 0.51.0
+ - Older
- TestFlight/Development build
validations:
required: true
@@ -90,9 +90,9 @@ body:
label: Jellyfin Server Information
description: Please provide details about your Jellyfin server
placeholder: |
- - Jellyfin Server Version: e.g. 10.10.7
- - Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
- - Connection: e.g. Local network, Remote via domain, VPN
+ - Jellyfin Server Version: e.g. 10.11.10
+ - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
+ - Connection: e.g. Local network, remote via domain, VPN
- type: textarea
id: screenshots
@@ -104,7 +104,7 @@ body:
id: logs
attributes:
label: Relevant logs (if available)
- description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
+ description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
render: shell
- type: textarea
diff --git a/.github/renovate.json b/.github/renovate.json
index fdbe3734..45c62042 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -44,22 +44,42 @@
]
}
},
- "lockFileMaintenance": {
- "vulnerabilityAlerts": {
- "enabled": true,
- "addLabels": ["security", "vulnerability"],
- "assigneesFromCodeOwners": true,
- "commitMessageSuffix": " [SECURITY]"
+ "vulnerabilityAlerts": {
+ "enabled": true,
+ "addLabels": ["security", "vulnerability"],
+ "assigneesFromCodeOwners": true,
+ "commitMessageSuffix": " [SECURITY]"
+ },
+ "packageRules": [
+ {
+ "description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
+ "matchPackageNames": [
+ "expo",
+ "react",
+ "react-dom",
+ "react-native",
+ "react-native-web",
+ "expo-*",
+ "@expo/*"
+ ],
+ "groupName": "Expo SDK",
+ "dependencyDashboardApproval": true
},
- "packageRules": [
- {
- "description": "Group minor and patch GitHub Action updates into a single PR",
- "matchManagers": ["github-actions"],
- "groupName": "CI dependencies",
- "groupSlug": "ci-deps",
- "matchUpdateTypes": ["minor", "patch", "digest", "pin"],
- "automerge": true
- }
- ]
- }
+ {
+ "description": "Group minor and patch GitHub Action updates into a single PR",
+ "matchManagers": ["github-actions"],
+ "groupName": "CI dependencies",
+ "groupSlug": "ci-deps",
+ "matchUpdateTypes": ["minor", "patch", "digest", "pin"],
+ "automerge": true
+ },
+ {
+ "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
+ "matchDatasources": ["maven"],
+ "registryUrls": [
+ "https://dl.google.com/dl/android/maven2/",
+ "https://repo.maven.apache.org/maven2/"
+ ]
+ }
+ ]
}
diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index fd68e23a..c3fee61b 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -11,6 +11,15 @@ on:
push:
branches: [develop, master]
+# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
+# branch + commit + Actions run a CI build was made from. EAS cloud builds use
+# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
+# run (artifacts + logs) without needing Expo access.
+env:
+ EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
+ EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
+ EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
+
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
@@ -33,7 +42,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -116,7 +125,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -187,7 +196,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -219,10 +228,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
- xcode-version: "26.4"
+ xcode-version: "26.5"
- name: 🏗️ Setup EAS
- uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -231,7 +240,9 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
- run: eas build -p ios --local --non-interactive
+ # `ci` profile (extends production, autoIncrement off): keeps CI builds out of
+ # the production version tier and stops them inflating the store build counter.
+ run: eas build -p ios --local --non-interactive --profile ci
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -252,7 +263,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -284,7 +295,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
- xcode-version: "26.4"
+ xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
@@ -312,7 +323,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -344,10 +355,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
- xcode-version: "26.4"
+ xcode-version: "26.5"
- name: 🏗️ Setup EAS
- uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -356,7 +367,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
- run: eas build -p ios --local --non-interactive
+ run: eas build -p ios --local --non-interactive --profile ci_tv
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -380,7 +391,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -412,7 +423,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
- xcode-version: "26.4"
+ xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml
index ae4c0fe0..0cb8afc3 100644
--- a/.github/workflows/check-lockfile.yml
+++ b/.github/workflows/check-lockfile.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml
index ba1c08dc..f79cf58a 100644
--- a/.github/workflows/ci-codeql.yml
+++ b/.github/workflows/ci-codeql.yml
@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🏁 Initialize CodeQL
- uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
- uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis
- uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
diff --git a/.github/workflows/conflict.yml b/.github/workflows/conflict.yml
index 7793851c..de854ab6 100644
--- a/.github/workflows/conflict.yml
+++ b/.github/workflows/conflict.yml
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index c6effebf..b0ea48a2 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -1,51 +1,51 @@
-name: 🌐 Translation Sync
-
-on:
- push:
- branches: [develop]
- paths:
- - "translations/**"
- - "crowdin.yml"
- - "i18n.ts"
- - ".github/workflows/crowdin.yml"
- # Run weekly to pull new translations
- schedule:
- - cron: "0 2 * * 1" # Every Monday at 2 AM UTC
- workflow_dispatch:
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- sync-translations:
- runs-on: ubuntu-latest
-
- steps:
- - name: 📥 Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
-
- - name: 🌐 Sync Translations with Crowdin
- uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
- with:
- upload_sources: true
- upload_translations: true
- download_translations: true
- localization_branch_name: I10n_crowdin_translations
- create_pull_request: true
- pull_request_title: "feat: New Crowdin Translations"
- pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
- pull_request_base_branch_name: "develop"
- pull_request_labels: "🌐 translation"
- # Quality control options
- skip_untranslated_strings: false
- skip_untranslated_files: false
- export_only_approved: false
- # Commit customization
- commit_message: "feat(i18n): update translations from Crowdin"
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
- CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+name: 🌐 Translation Sync
+
+on:
+ push:
+ branches: [develop]
+ paths:
+ - "translations/**"
+ - "crowdin.yml"
+ - "i18n.ts"
+ - ".github/workflows/crowdin.yml"
+ # Run weekly to pull new translations
+ schedule:
+ - cron: "0 2 * * 1" # Every Monday at 2 AM UTC
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ sync-translations:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: 📥 Checkout Repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ fetch-depth: 0
+
+ - name: 🌐 Sync Translations with Crowdin
+ uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
+ with:
+ upload_sources: true
+ upload_translations: true
+ download_translations: true
+ localization_branch_name: I10n_crowdin_translations
+ create_pull_request: true
+ pull_request_title: "feat: New Crowdin Translations"
+ pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
+ pull_request_base_branch_name: "develop"
+ pull_request_labels: "🌐 translation"
+ # Quality control options
+ skip_untranslated_strings: false
+ skip_untranslated_files: false
+ export_only_approved: false
+ # Commit customization
+ commit_message: "feat(i18n): update translations from Crowdin"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml
new file mode 100644
index 00000000..265f9efe
--- /dev/null
+++ b/.github/workflows/detect-duplicate.yml
@@ -0,0 +1,38 @@
+name: 🔁 Detect Duplicate Issues
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: detect-duplicate-${{ github.event.issue.number }}
+ cancel-in-progress: true
+
+jobs:
+ detect:
+ name: 🔍 Find similar issues
+ if: github.actor != 'github-actions[bot]'
+ runs-on: ubuntu-24.04
+ permissions:
+ issues: write
+ contents: read
+ steps:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: latest
+
+ - name: 🔍 Detect duplicate issues
+ run: bun scripts/detect-duplicate-issue.mjs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_TITLE: ${{ github.event.issue.title }}
+ ISSUE_BODY: ${{ github.event.issue.body }}
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index 50013ba2..8edb8916 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -97,10 +97,11 @@ jobs:
- "check"
- "format"
- "typecheck"
+ - "i18n:check"
steps:
- name: "📥 Checkout PR code"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..c06e8b34
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,216 @@
+name: 🚀 Release (EAS build + submit)
+
+# On merge to main (gated by the `production` GitHub Environment approval),
+# build all targets on EAS in parallel via custom bun build configs:
+# 1. iOS phone → App Store (auto-submit)
+# 2. tvOS → App Store (auto-submit)
+# 3. Android AAB → Google Play (auto-submit)
+# 4. Android phone APK→ downloadable artifact
+# 5. Android TV APK → downloadable artifact
+# Note: EAS queues builds based on your plan's concurrency; parallel jobs
+# here just submit them — EAS may still run them serially.
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ approve:
+ name: 🔐 Approve release
+ runs-on: ubuntu-24.04
+ environment: production
+ steps:
+ - name: ✅ Release approved
+ run: echo "Release approved for ${{ github.sha }}"
+
+ build:
+ name: 🚀 ${{ matrix.name }}
+ needs: approve
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: 🍎 iOS
+ platform: ios
+ profile: production
+ submit: true
+ - name: 📺 tvOS
+ platform: ios
+ profile: production_tv
+ submit: true
+ - name: 🤖 Android AAB
+ platform: android
+ profile: production
+ submit: true
+ - name: 🤖 Android APK
+ platform: android
+ profile: production-apk
+ submit: false
+ artifact_name: streamyfin-android-phone-apk
+ - name: 📺 Android TV APK
+ platform: android
+ profile: production-apk-tv
+ submit: false
+ artifact_name: streamyfin-android-tv-apk
+
+ steps:
+ - name: 📥 Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: latest
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-cache
+
+ - name: 📦 Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ - name: 🏗️ Setup EAS
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
+ with:
+ eas-version: latest
+ token: ${{ secrets.EXPO_TOKEN }}
+ eas-cache: true
+
+ # tvOS uses credentialsSource: local — restore the gitignored
+ # credentials.json + cert + provisioning profiles from secrets.
+ - name: 🔐 Restore tvOS signing credentials
+ if: matrix.profile == 'production_tv'
+ env:
+ EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
+ TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
+ TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
+ TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
+ run: |
+ mkdir -p certs profiles
+ printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
+ echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
+ echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
+ echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
+
+ # Android Play submit needs the Google Play service account JSON.
+ - name: 🔐 Restore Google Play service account
+ if: matrix.platform == 'android' && matrix.submit
+ env:
+ GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
+ run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
+
+ # App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
+ - name: 🔐 Restore App Store Connect API key
+ if: matrix.platform == 'ios'
+ env:
+ APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
+ run: |
+ if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
+ printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
+ else
+ printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
+ fi
+
+ # ── Submit builds: cloud build + auto-submit to the store ──
+ - name: 🚀 Build & submit (${{ matrix.name }})
+ if: matrix.submit
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
+ EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
+ EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
+ run: |
+ eas build \
+ --platform ${{ matrix.platform }} \
+ --profile ${{ matrix.profile }} \
+ --auto-submit \
+ --non-interactive \
+ --wait
+
+ # ── Artifact builds: cloud build, then download + upload the APK ──
+ - name: 🏗️ Build artifact (${{ matrix.name }})
+ if: ${{ !matrix.submit }}
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: |
+ eas build \
+ --platform ${{ matrix.platform }} \
+ --profile ${{ matrix.profile }} \
+ --non-interactive \
+ --wait \
+ --json > build-result.json
+ URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
+ echo "Downloading artifact: $URL"
+ curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
+
+ - name: 📤 Upload APK artifact (${{ matrix.name }})
+ if: ${{ !matrix.submit }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: ${{ matrix.artifact_name }}.apk
+ retention-days: 14
+
+ # Draft a GitHub Release with the two APKs attached. The tag comes from the
+ # merged-in app version (app.json → expo.version), NOT the auto-incremented
+ # build number — so cutting a release is a deliberate version bump via PR.
+ github-release:
+ name: 📦 Draft GitHub Release
+ needs: build
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ actions: read # required for `gh run download` to list/fetch this run's artifacts
+ steps:
+ - name: 📥 Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ fetch-depth: 0
+ show-progress: false
+
+ - name: 📦 Download APK artifacts from this run
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ mkdir -p apks
+ gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
+ gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
+ ls -la apks
+
+ - name: 📝 Draft release (tag = app.json version, not auto-bumped)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION=$(node -e "console.log(require('./app.json').expo.version)")
+ TAG="v$VERSION"
+ echo "Release tag from merged app version: $TAG"
+ if gh release view "$TAG" >/dev/null 2>&1; then
+ echo "Release $TAG exists — updating APK assets"
+ gh release upload "$TAG" apks/*.apk --clobber
+ else
+ echo "Creating draft release $TAG"
+ gh release create "$TAG" \
+ --draft \
+ --generate-notes \
+ --title "$TAG" \
+ apks/*.apk
+ fi
diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml
new file mode 100644
index 00000000..4972e14f
--- /dev/null
+++ b/.github/workflows/trivy-scan.yml
@@ -0,0 +1,60 @@
+name: 🛡️ Trivy Security Scan
+
+# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
+# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
+# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
+# upload needs a write token that fork PRs don't get).
+on:
+ push:
+ branches: [develop, master]
+ schedule:
+ - cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: trivy-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ trivy:
+ name: 🔎 Filesystem scan
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ security-events: write # upload SARIF to code scanning
+ steps:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ # Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
+ # instead of a fresh immutable entry per run, still refreshing the DB every week.
+ - name: 🗓️ Compute weekly Trivy cache key
+ id: trivy-cache-key
+ run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
+
+ - name: 💾 Cache Trivy vulnerability DB
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.cache/trivy
+ key: ${{ steps.trivy-cache-key.outputs.value }}
+ restore-keys: trivy-db-${{ runner.os }}-
+
+ - name: 🔎 Run Trivy filesystem scan
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ scanners: vuln,secret,misconfig
+ ignore-unfixed: true
+ severity: CRITICAL,HIGH
+ format: sarif
+ output: trivy-results.sarif
+
+ - name: 📤 Upload results to code scanning
+ uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ with:
+ sarif_file: trivy-results.sarif
+ category: trivy-fs
diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml
index 7cc32197..a23ecdf2 100644
--- a/.github/workflows/update-issue-form.yml
+++ b/.github/workflows/update-issue-form.yml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
diff --git a/.gitignore b/.gitignore
index c39e191b..d46c8a6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
# Dependencies and Package Managers
node_modules/
-bun.lock
bun.lockb
package-lock.json
@@ -18,10 +17,11 @@ web-build/
/androidmobile
/androidtv
-# Module-specific Builds
-modules/mpv-player/android/build
-modules/player/android
-modules/hls-downloader/android/build
+# Gradle caches (top-level + per-module native projects)
+**/.gradle/
+
+# Native module build outputs (any module)
+modules/*/android/build/
# Generated Applications
Streamyfin.app
@@ -66,13 +66,15 @@ certs/
# Version and Backup Files
/version-backup-*
-/modules/sf-player/android/build
-/modules/music-controls/android/build
-modules/background-downloader/android/build/*
-/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/
+# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
+!.eas/build/
+!.eas/build/**
.claude/
.agents/skills/**
skills-lock.json
+
+# CI-injected Google Play service account key (written at build time)
+google-service-account.json
diff --git a/app.config.js b/app.config.js
index 96bbd8ea..d29ddc32 100644
--- a/app.config.js
+++ b/app.config.js
@@ -1,3 +1,47 @@
+const { execFileSync } = require("node:child_process");
+
+// Build metadata, injected into `extra.build` and read at runtime via
+// expo-constants (see utils/version.ts). Sources in priority order:
+// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
+const git = (args) => {
+ try {
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
+ .toString()
+ .trim();
+ } catch {
+ return null;
+ }
+};
+
+const buildMeta = {
+ commit:
+ (
+ process.env.EAS_BUILD_GIT_COMMIT_HASH ||
+ process.env.GITHUB_SHA ||
+ process.env.EXPO_PUBLIC_GIT_COMMIT ||
+ git(["rev-parse", "HEAD"]) ||
+ ""
+ ).slice(0, 7) || null,
+ branch:
+ process.env.EAS_BUILD_GIT_BRANCH ||
+ process.env.GITHUB_HEAD_REF ||
+ process.env.GITHUB_REF_NAME ||
+ process.env.EXPO_PUBLIC_GIT_BRANCH ||
+ git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
+ null,
+ profile:
+ process.env.EAS_BUILD_PROFILE ||
+ process.env.EXPO_PUBLIC_BUILD_PROFILE ||
+ null,
+ // GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
+ // to its Actions run (artifacts + logs) without Expo access. Null outside CI.
+ runNumber:
+ process.env.GITHUB_RUN_NUMBER ||
+ process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
+ null,
+ builtAt: new Date().toISOString(),
+};
+
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
+ config.extra = { ...config.extra, build: buildMeta };
+
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
diff --git a/app.json b/app.json
index 6dee6c85..296d674d 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.54.0",
+ "version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,6 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
- "versionCode": 93,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
diff --git a/app/(auth)/(tabs)/(favorites)/see-all.tsx b/app/(auth)/(tabs)/(favorites)/see-all.tsx
index e3b0198a..c885afdc 100644
--- a/app/(auth)/(tabs)/(favorites)/see-all.tsx
+++ b/app/(auth)/(tabs)/(favorites)/see-all.tsx
@@ -161,9 +161,7 @@ export default function FavoritesSeeAllScreen() {
/>
{!itemType ? (
-
- {t("favorites.noData", { defaultValue: "No items found." })}
-
+ {t("favorites.noData")}
) : isLoading ? (
@@ -194,7 +192,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={
- {t("home.no_items", { defaultValue: "No items" })}
+ {t("home.no_items")}
}
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index 884b1fbb..68340e6b 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -1,4 +1,9 @@
-import { BottomSheetModal } from "@gorhom/bottom-sheet";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
+import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
navigation.setOptions({
headerRight: () => (
bottomSheetModalRef.current?.present()}
className='px-2'
>
f.item) || []} />
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
}
}, [showMigration]);
- const _deleteMovies = () =>
+ const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -127,18 +133,18 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
- const _deleteShows = () =>
+ const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
- t("home.downloads.toasts.deleted_all_tvseries_successfully"),
+ t("home.downloads.toasts.deleted_all_series_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
- toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
+ toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
});
- const _deleteOtherMedia = () =>
+ const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
),
);
+ const deleteAllMedia = async () =>
+ await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
+
return (
- {t("home.downloads.tvseries")}
+ {t("home.downloads.series")}
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
)}
+ (
+
+ )}
+ >
+
+
+
+
+ {otherMedia.length > 0 && (
+
+ )}
+
+
+
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index db223b2b..69b980d3 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -59,17 +59,19 @@ function SettingsMobile() {
-
-
-
- router.push("/(auth)/(tabs)/(home)/companion-login")
- }
- title={t("pairing.pair_with_phone")}
- textColor='blue'
- />
-
-
+ {Platform.OS !== "ios" && (
+
+
+
+ router.push("/(auth)/(tabs)/(home)/companion-login")
+ }
+ title={t("pairing.pair_with_phone")}
+ textColor='blue'
+ />
+
+
+ )}
diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx
index 8fb8dcef..a9a2e2fb 100644
--- a/app/(auth)/(tabs)/(home)/settings.tv.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx
@@ -179,18 +179,15 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
- t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
- t(
- "home.settings.storage.clear_all_cache_confirm_desc",
- "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
- ),
+ t("home.settings.storage.clear_all_cache_confirm"),
+ t("home.settings.storage.clear_all_cache_confirm_desc"),
[
{
- text: t("common.cancel", "Cancel"),
+ text: t("common.cancel"),
style: "cancel",
},
{
- text: t("common.ok", "OK"),
+ text: t("common.ok"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
@@ -243,11 +240,8 @@ export default function SettingsTV() {
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
- t("home.settings.toasts.error_deleting_files", "Error"),
- t(
- "home.settings.storage.clear_all_cache_error_desc",
- "An error occurred while clearing the cache.",
- ),
+ t("home.settings.toasts.error_deleting_files"),
+ t("home.settings.storage.clear_all_cache_error_desc"),
);
}
},
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
index 1c4dcd19..8f0a2c93 100644
--- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
};
const handleRefreshFromServer = useCallback(async () => {
- const newPluginSettings = await refreshStreamyfinPluginSettings(true);
+ const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx
index 519d5e5c..214564eb 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx
@@ -6,6 +6,7 @@ import {
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
+import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState();
const [requestBody, _setRequestBody] = useState();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
- const advancedReqModalRef = useRef(null);
+ const advancedReqModalRef = useRef(null);
const bottomSheetModalRef = useRef(null);
const {
diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx
index acc7f817..ed31b438 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
-
+
{
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
+ tabBarActiveTintColor: "#FFFFFF",
+ tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600",
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 29d3748f..53fbeb91 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -102,8 +102,8 @@ export default function TabLayout() {
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon:
Platform.OS === "android"
- ? (_e) => require("@/assets/icons/list.png")
- : (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
+ ? (_e) => require("@/assets/icons/list.star.png")
+ : (_e) => ({ sfSymbol: "list.star" }),
}}
/>
require("@/assets/icons/server.rack.png")
+ ? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
}}
/>
@@ -123,8 +123,8 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon:
Platform.OS === "android"
- ? (_e) => require("@/assets/icons/list.png")
- : (_e) => ({ sfSymbol: "list.dash.fill" }),
+ ? (_e) => require("@/assets/icons/link.png")
+ : (_e) => ({ sfSymbol: "link" }),
}}
/>
require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
+ ? (_e) => require("@/assets/icons/gearshape.fill.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 0314a6e7..f3e83f0d 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -291,6 +291,11 @@ export default function DirectPlayerPage() {
};
if (itemId) {
+ setItem(null);
+ setDownloadedItem(null);
+ // Clear the previous episode's stream so the loader gate stays closed
+ // until the new item's stream resolves (avoids a stale MPV source frame).
+ setStream(null);
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
@@ -333,6 +338,12 @@ export default function DirectPlayerPage() {
return null;
}
+ // Ensure item matches the current itemId to avoid race conditions
+ if (item.Id !== itemId) {
+ setStreamStatus({ isLoading: false, isError: false });
+ return null;
+ }
+
let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath;
@@ -405,6 +416,7 @@ export default function DirectPlayerPage() {
item,
user?.Id,
downloadedItem,
+ offline,
]);
useEffect(() => {
@@ -444,21 +456,15 @@ export default function DirectPlayerPage() {
if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get());
- await getPlaystateApi(api).onPlaybackStopped({
- itemId: item.Id,
- mediaSourceId: mediaSourceId,
- positionTicks: currentTimeInTicks,
- playSessionId: stream.sessionId,
+ await getPlaystateApi(api).reportPlaybackStopped({
+ playbackStopInfo: {
+ ItemId: item.Id,
+ MediaSourceId: mediaSourceId,
+ PositionTicks: currentTimeInTicks,
+ PlaySessionId: stream.sessionId,
+ },
});
- }, [
- api,
- item,
- mediaSourceId,
- stream,
- progress,
- offline,
- revalidateProgressCache,
- ]);
+ }, [api, item, mediaSourceId, stream, progress, offline]);
const stop = useCallback(() => {
// Update URL with final playback position before stopping
@@ -476,9 +482,10 @@ export default function DirectPlayerPage() {
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
+ reportPlaybackStopped();
beforeRemoveListener();
};
- }, [navigation, stop]);
+ }, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo
diff --git a/assets/icons/gear.png b/assets/icons/gear.png
deleted file mode 100644
index f5b98cf0..00000000
Binary files a/assets/icons/gear.png and /dev/null differ
diff --git a/assets/icons/gearshape.fill.png b/assets/icons/gearshape.fill.png
new file mode 100644
index 00000000..a3ee5bfe
Binary files /dev/null and b/assets/icons/gearshape.fill.png differ
diff --git a/assets/icons/heart.fill.png b/assets/icons/heart.fill.png
index 25bb2527..fd868d99 100644
Binary files a/assets/icons/heart.fill.png and b/assets/icons/heart.fill.png differ
diff --git a/assets/icons/heart.png b/assets/icons/heart.png
deleted file mode 100644
index 96a448a7..00000000
Binary files a/assets/icons/heart.png and /dev/null differ
diff --git a/assets/icons/house.fill.png b/assets/icons/house.fill.png
index 9e32f71e..aa6f116c 100644
Binary files a/assets/icons/house.fill.png and b/assets/icons/house.fill.png differ
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg
deleted file mode 100644
index 1f8b997d..00000000
--- a/assets/icons/jellyseerr-logo.svg
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
diff --git a/assets/icons/link.png b/assets/icons/link.png
new file mode 100644
index 00000000..95d08787
Binary files /dev/null and b/assets/icons/link.png differ
diff --git a/assets/icons/list.png b/assets/icons/list.png
deleted file mode 100644
index 3c548bb4..00000000
Binary files a/assets/icons/list.png and /dev/null differ
diff --git a/assets/icons/list.star.png b/assets/icons/list.star.png
new file mode 100644
index 00000000..cfa85c3a
Binary files /dev/null and b/assets/icons/list.star.png differ
diff --git a/assets/icons/magnifyingglass.png b/assets/icons/magnifyingglass.png
index 5fc44c41..d62b6482 100644
Binary files a/assets/icons/magnifyingglass.png and b/assets/icons/magnifyingglass.png differ
diff --git a/assets/icons/rectangle.stack.fill.png b/assets/icons/rectangle.stack.fill.png
new file mode 100644
index 00000000..86460d70
Binary files /dev/null and b/assets/icons/rectangle.stack.fill.png differ
diff --git a/assets/icons/seerr-logo.svg b/assets/icons/seerr-logo.svg
new file mode 100644
index 00000000..a0e32e79
--- /dev/null
+++ b/assets/icons/seerr-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/server.rack.png b/assets/icons/server.rack.png
deleted file mode 100644
index 245e5ad2..00000000
Binary files a/assets/icons/server.rack.png and /dev/null differ
diff --git a/assets/images/not-rotten-tomatoes.svg b/assets/images/not-rotten-tomatoes.svg
deleted file mode 100644
index 18fa58b8..00000000
--- a/assets/images/not-rotten-tomatoes.svg
+++ /dev/null
@@ -1,65 +0,0 @@
-
diff --git a/assets/images/rotten-tomatoes.png b/assets/images/rotten-tomatoes.png
deleted file mode 100644
index 341b62b0..00000000
Binary files a/assets/images/rotten-tomatoes.png and /dev/null differ
diff --git a/assets/images/rt_aud_fresh.svg b/assets/images/rt_aud_fresh.svg
new file mode 100644
index 00000000..f9fa2904
--- /dev/null
+++ b/assets/images/rt_aud_fresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_aud_rotten.svg b/assets/images/rt_aud_rotten.svg
new file mode 100644
index 00000000..cd84ac5b
--- /dev/null
+++ b/assets/images/rt_aud_rotten.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_fresh.svg b/assets/images/rt_fresh.svg
new file mode 100644
index 00000000..ed6f44d7
--- /dev/null
+++ b/assets/images/rt_fresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_rotten.svg b/assets/images/rt_rotten.svg
new file mode 100644
index 00000000..60ba169e
--- /dev/null
+++ b/assets/images/rt_rotten.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/tmdb_logo.svg b/assets/images/tmdb_logo.svg
new file mode 100644
index 00000000..bdf988ba
--- /dev/null
+++ b/assets/images/tmdb_logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index ed6a2e46..97ba4fa2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,9 +11,10 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
- "@gorhom/bottom-sheet": "5.2.8",
+ "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
+ "@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -83,6 +84,7 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
+ "react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
@@ -106,7 +108,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
- "expo-doctor": "1.19.7",
+ "expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",
@@ -114,11 +116,6 @@
},
},
},
- "patchedDependencies": {
- "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
- "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
- "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
- },
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -368,7 +365,7 @@
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
- "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
+ "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -542,6 +539,10 @@
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
+ "@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
+
+ "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
+
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
@@ -968,7 +969,7 @@
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
- "expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
+ "expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
@@ -1594,9 +1595,11 @@
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
+ "react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
+
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
- "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
+ "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
@@ -2006,6 +2009,10 @@
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
+ "@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -2224,6 +2231,14 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
+ "@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
+ "@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2336,6 +2351,14 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
+ "@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx
index 0a744e32..2b04b114 100644
--- a/components/IntroSheet.tsx
+++ b/components/IntroSheet.tsx
@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef((_, ref) => {
- Jellyseerr
+ Seerr
{t("home.intro.jellyseerr_feature_description")}
diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx
index aaea71b3..5487393d 100644
--- a/components/PlatformDropdown.tsx
+++ b/components/PlatformDropdown.tsx
@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
-import React, { useEffect, useState } from "react";
-import {
- type LayoutChangeEvent,
- Platform,
- StyleSheet,
- TouchableOpacity,
- View,
-} from "react-native";
+import React, { useEffect } from "react";
+import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
- // @expo/ui's (SDK 55) fills its available space by default, and
- // `matchContents` doesn't help here: it reports the native Menu's size via
- // setStyleSize and overrides any explicit size. Instead we measure the
- // trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
- const [triggerSize, setTriggerSize] = useState<{
- width: number;
- height: number;
- } | null>(null);
-
- const handleMeasureTrigger = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout;
- setTriggerSize((prev) =>
- prev && prev.width === width && prev.height === height
- ? prev
- : { width, height },
- );
- };
-
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
- // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55)
- // fills its parent and reports its own size via setStyleSize, so it can't
- // size itself to content. If the wrapper has no size, the Host's `flex: 1`
- // height depends on the parent while the parent depends on the Host — a
- // circular dependency that collapses to 0 for any selector nested more than
- // one level deep (so only the first, shallowest dropdown stays visible).
- // Giving the wrapper the measured size breaks the cycle; the Host then
- // fills a concrete box.
+ // @expo/ui's can't size to content, so an in-flow invisible copy of
+ // the trigger sizes the wrapper while the Host overlays the real Menu.
return (
-
- {/* Hidden measurer: lays the trigger out off-flow to capture its
- intrinsic size. Absolutely positioned WITHOUT right/bottom so it
- sizes to the trigger's content rather than to its parent. */}
-
+
+
{trigger}
diff --git a/components/Ratings.tsx b/components/Ratings.tsx
index 5741233f..2e06403f 100644
--- a/components/Ratings.tsx
+++ b/components/Ratings.tsx
@@ -40,8 +40,8 @@ export const Ratings: React.FC = ({ item, ...props }) => {
= ({
[isAndroid],
);
+ const isPresentedRef = useRef(false);
+
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
- } else {
+ } else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
+ isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
- if (index === -1) {
+ if (index >= 0) {
+ isPresentedRef.current = true;
+ } else if (index === -1 && isPresentedRef.current) {
+ isPresentedRef.current = false;
resetState();
onClose();
}
diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx
index 4dda0564..57e4a029 100644
--- a/components/TrackSheet.tsx
+++ b/components/TrackSheet.tsx
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC = ({
{selected === -1 && streamType === "Subtitle"
? t("common.none")
- : selectedSteam?.DisplayTitle || t("common.select", "Select")}
+ : selectedSteam?.DisplayTitle || t("common.select")}
diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx
index 42a90b89..e4433209 100644
--- a/components/chapters/ChapterList.tsx
+++ b/components/chapters/ChapterList.tsx
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
+import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import {
type ChapterEntry,
chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
+ const safeArea = useControlsSafeAreaInsets();
const listRef = useRef>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -74,9 +76,22 @@ function ChapterListComponent({
transparent
animationType='slide'
onRequestClose={onClose}
+ // iOS defaults to portrait-only; without this it rotates the app
+ // back to portrait when opened from the landscape player. Android ignores it.
+ supportedOrientations={["portrait", "landscape"]}
>
- e.stopPropagation()} style={styles.sheet}>
+ e.stopPropagation()}
+ style={[
+ styles.sheet,
+ {
+ marginLeft: safeArea.left,
+ marginRight: safeArea.right,
+ paddingBottom: safeArea.bottom,
+ },
+ ]}
+ >
{t("chapters.title")}
= ({ item }) => {
}
/>
>
);
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index cc40d2dc..fed45dc9 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
+import { useTranslation } from "react-i18next";
import {
Platform,
TouchableOpacity,
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC> = ({
children,
...props
}) => {
+ const { t } = useTranslation();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC> = ({
return;
const options: string[] = [
- "Mark as Played",
- "Mark as Not Played",
- isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
- ...(isOffline ? ["Delete Download"] : []),
- "Cancel",
+ t("common.mark_as_played"),
+ t("common.mark_as_not_played"),
+ isFavorite
+ ? t("music.track_options.remove_from_favorites")
+ : t("music.track_options.add_to_favorites"),
+ ...(isOffline ? [t("home.downloads.delete_download")] : []),
+ t("common.cancel"),
];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC> = ({
isOffline,
deleteFile,
item.Id,
+ t,
]);
if (
diff --git a/components/home/Home.tsx b/components/home/Home.tsx
index 637e2041..060dafed 100644
--- a/components/home/Home.tsx
+++ b/components/home/Home.tsx
@@ -133,7 +133,6 @@ const HomeMobile = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
- className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
= ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto;
}, [item, people]);
- const topPeople = useMemo(() => people.slice(0, 3), [people]);
+ // Jellyfin can list the same person several times (e.g. an actor also
+ // credited as writer). Dedupe by Id so the same actor section isn't rendered
+ // twice and we still surface 3 distinct people.
+ const topPeople = useMemo(() => {
+ const seen = new Set();
+ const unique: BaseItemPerson[] = [];
+ for (const person of people) {
+ if (!person.Id || seen.has(person.Id)) continue;
+ seen.add(person.Id);
+ unique.push(person);
+ if (unique.length >= 3) break;
+ }
+ return unique;
+ }, [people]);
const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => {
diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx
index 8d0168b2..cf38fa54 100644
--- a/components/login/TVAddServerForm.tsx
+++ b/components/login/TVAddServerForm.tsx
@@ -1,6 +1,6 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
-import { ScrollView, View } from "react-native";
+import { Platform, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC = ({
{/* Pair with Phone */}
- {onStartPairing && (
+ {Platform.OS !== "ios" && onStartPairing && (
diff --git a/eas.json b/eas.json
index 03f93389..f4099eda 100644
--- a/eas.json
+++ b/eas.json
@@ -1,6 +1,7 @@
{
"cli": {
- "version": ">= 9.1.0"
+ "version": ">= 16.0.0",
+ "appVersionSource": "remote"
},
"build": {
"development": {
@@ -51,44 +52,78 @@
}
},
"production": {
+ "bun": "1.3.5",
"environment": "production",
- "channel": "0.54.0",
+ "autoIncrement": true,
"android": {
- "image": "latest"
+ "image": "latest",
+ "config": "android-production.yml"
+ },
+ "ios": {
+ "config": "ios-production.yml"
}
},
"production-apk": {
+ "bun": "1.3.5",
"environment": "production",
- "channel": "0.54.0",
+ "autoIncrement": true,
"android": {
"buildType": "apk",
- "image": "latest"
+ "image": "latest",
+ "config": "android-production-apk.yml"
}
},
"production-apk-tv": {
+ "bun": "1.3.5",
"environment": "production",
- "channel": "0.54.0",
+ "autoIncrement": true,
"android": {
"buildType": "apk",
- "image": "latest"
+ "image": "latest",
+ "config": "android-production-tv.yml"
},
"env": {
"EXPO_TV": "1"
}
},
"production_tv": {
+ "bun": "1.3.5",
"environment": "production",
- "channel": "0.54.0",
+ "autoIncrement": true,
"env": {
"EXPO_TV": "1"
},
"ios": {
- "credentialsSource": "local"
+ "credentialsSource": "local",
+ "config": "ios-production.yml"
}
+ },
+ "ci": {
+ "extends": "production",
+ "autoIncrement": false
+ },
+ "ci_tv": {
+ "extends": "production_tv",
+ "autoIncrement": false
}
},
"submit": {
- "production": {},
- "production_tv": {}
+ "production": {
+ "ios": {
+ "appleTeamId": "MWD5K362T8",
+ "ascAppId": "6593660679"
+ },
+ "android": {
+ "serviceAccountKeyPath": "./google-service-account.json",
+ "track": "internal",
+ "releaseStatus": "completed"
+ }
+ },
+ "production_tv": {
+ "ios": {
+ "appleTeamId": "MWD5K362T8",
+ "ascAppId": "6593660679"
+ }
+ }
}
}
diff --git a/hooks/useControlsSafeAreaInsets.ts b/hooks/useControlsSafeAreaInsets.ts
new file mode 100644
index 00000000..4fa4968e
--- /dev/null
+++ b/hooks/useControlsSafeAreaInsets.ts
@@ -0,0 +1,18 @@
+import {
+ type EdgeInsets,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+import { useSettings } from "@/utils/atoms/settings";
+
+const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
+
+/**
+ * Returns safe-area insets to apply to in-player controls, honoring the
+ * `safeAreaInControlsEnabled` user setting. When the setting is disabled,
+ * returns zero insets so controls can sit flush against the screen edges.
+ */
+export const useControlsSafeAreaInsets = (): EdgeInsets => {
+ const { settings } = useSettings();
+ const insets = useSafeAreaInsets();
+ return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
+};
diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts
index ec66c505..b5b6896c 100644
--- a/hooks/useImageStorage.ts
+++ b/hooks/useImageStorage.ts
@@ -1,3 +1,4 @@
+import { File, Paths } from "expo-file-system";
import { useCallback } from "react";
import { storage } from "@/utils/mmkv";
@@ -12,36 +13,28 @@ const useImageStorage = () => {
}
}, []);
+ /**
+ * expo-file-system instead of fetch+Blob+FileReader: the latter silently
+ * resolves to an empty payload under RN's New Architecture.
+ */
const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null;
- let blob: Blob;
+ const tmpFile = new File(
+ Paths.cache,
+ `img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
+ );
try {
- // Fetch the data from the URL
- const response = await fetch(url);
- blob = await response.blob();
+ const downloaded = await File.downloadFileAsync(url, tmpFile, {
+ idempotent: true,
+ });
+ return await downloaded.base64();
} catch (error) {
console.warn("Error fetching image:", error);
return null;
+ } finally {
+ if (tmpFile.exists) tmpFile.delete();
}
-
- // Create a FileReader instance
- const reader = new FileReader();
-
- // Convert blob to base64
- return new Promise((resolve, reject) => {
- reader.onloadend = () => {
- if (typeof reader.result === "string") {
- // Extract the base64 string (remove the data URL prefix)
- const base64 = reader.result.split(",")[1];
- resolve(base64);
- } else {
- reject(new Error("Failed to convert image to base64"));
- }
- };
- reader.onerror = reject;
- reader.readAsDataURL(blob);
- });
}, []);
const saveImage = useCallback(
diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts
index 97d14845..ac90c20a 100644
--- a/hooks/usePlaybackManager.ts
+++ b/hooks/usePlaybackManager.ts
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0,
});
+ /**
+ * Derive prev/next from the current item's real position in the adjacent
+ * list rather than from the array length. `getEpisodes({ adjacentTo })` does
+ * not guarantee a fixed [prev, current, next] shape — at the first/last
+ * episode it can still return the current item as the first/last entry — so
+ * length-based indexing wrongly surfaces the current episode as "previous".
+ */
+ const currentIndex = useMemo(
+ () => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
+ [adjacentItems, item],
+ );
+
+ /** A neighbour is only navigable if it has an actual media file (not a
+ * "Virtual"/missing episode placeholder, e.g. an absent Special). */
+ const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
+ !!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
+
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]);
+ if (!adjacentItems || currentIndex <= 0) return null;
+ const candidate = adjacentItems[currentIndex - 1];
+ return isNavigable(candidate) ? candidate : null;
+ }, [adjacentItems, currentIndex, item]);
/** The next item in the series */
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]);
+ if (!adjacentItems || currentIndex < 0) return null;
+ const candidate = adjacentItems[currentIndex + 1];
+ return isNavigable(candidate) ? candidate : null;
+ }, [adjacentItems, currentIndex, item]);
/**
* Reports playback progress.
diff --git a/modules/background-downloader/android/build.gradle b/modules/background-downloader/android/build.gradle
index 1b273d72..f2987348 100644
--- a/modules/background-downloader/android/build.gradle
+++ b/modules/background-downloader/android/build.gradle
@@ -1,46 +1,20 @@
-plugins {
- id 'com.android.library'
- id 'kotlin-android'
-}
+apply plugin: 'expo-module-gradle-plugin'
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
-def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
-def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
-
-apply from: expoModulesCorePlugin
-
-applyKotlinExpoModulesCorePlugin()
-useDefaultAndroidSdkVersions()
-useCoreDependencies()
-useExpoPublishing()
+expoModule {
+ canBePublished false
+}
android {
namespace "expo.modules.backgrounddownloader"
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = "17"
- }
-
- lintOptions {
- abortOnError false
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
}
}
dependencies {
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
-
-tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
- kotlinOptions {
- jvmTarget = "17"
- }
-}
-
diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
index 753bfb28..8b6808fd 100644
--- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
+++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
@@ -715,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection.
- if (initialAudioId != null && initialAudioId > 0) {
- setAudioTrack(initialAudioId)
- }
+ initialAudioId?.let { if (it > 0) setAudioTrack(it) }
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) {
diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift
index fe43d896..76891686 100644
--- a/modules/mpv-player/ios/MpvPlayerModule.swift
+++ b/modules/mpv-player/ios/MpvPlayerModule.swift
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
}
// Defines events that the view can send to JavaScript
- Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
+ Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}
diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift
index 0b3158e7..41f19eb0 100644
--- a/modules/mpv-player/ios/MpvPlayerView.swift
+++ b/modules/mpv-player/ios/MpvPlayerView.swift
@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher()
let onError = EventDispatcher()
let onTracksReady = EventDispatcher()
+ let onPictureInPictureChange = EventDispatcher()
private var currentURL: URL?
private var cachedPosition: Double = 0
@@ -81,7 +82,6 @@ class MpvPlayerView: ExpoView {
private func setupView() {
clipsToBounds = true
backgroundColor = .black
- configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,21 +141,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
+ // MARK: - Audio Session & Notifications
+
private func configureAudioSession() {
- let audioSession = AVAudioSession.sharedInstance()
+ let session = AVAudioSession.sharedInstance()
do {
- try audioSession.setCategory(
- .playback,
- mode: .moviePlayback,
- policy: .longFormAudio,
- options: []
- )
- try audioSession.setActive(true)
+ try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
+ try session.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
- // MARK: - Audio Session & Notifications
+
+ /// Deactivate the session AND reset the category — `setActive(false)` alone
+ /// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
+ /// reactivation (foreground, route change, other modules) re-steals audio.
+ private func tearDownAudioSession() {
+ let session = AVAudioSession.sharedInstance()
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
+ try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
+ }
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -270,6 +275,7 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
+ configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -440,6 +446,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
+ tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -519,9 +526,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
- // Audio output is now active - this is the right time to activate audio session and set Now Playing
- print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
- nowPlayingManager.activateAudioSession()
+ print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
syncNowPlaying(isPlaying: !isPaused())
}
}
@@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
+ // Notify JS of the actual PiP active state. `didStartPictureInPicture`
+ // is `false` when AVKit reports a failure to start, so reflect that.
+ onPictureInPictureChange(["isActive": didStartPictureInPicture])
}
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill
}
+ // Notify JS that PiP has fully stopped so the controls overlay can
+ // be re-mounted when the user returns to full screen.
+ onPictureInPictureChange(["isActive": false])
}
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
diff --git a/modules/mpv-player/src/MpvPlayerView.web.tsx b/modules/mpv-player/src/MpvPlayerView.web.tsx
index dc322ad7..a5252b4b 100644
--- a/modules/mpv-player/src/MpvPlayerView.web.tsx
+++ b/modules/mpv-player/src/MpvPlayerView.web.tsx
@@ -1,11 +1,13 @@
+import { useTranslation } from "react-i18next";
import { MpvPlayerViewProps } from "./MpvPlayer.types";
export default function MpvPlayerView(props: MpvPlayerViewProps) {
const url = props.source?.url ?? "";
+ const { t } = useTranslation();
return (