Compare commits

...

18 Commits

Author SHA1 Message Date
Fredrik Burmester
e1ac98b597 fix(tv): scale option-modal cards with text size + honor cardWidth
The TV option modal hardcoded the card size to scaleSize(160)/(75),
ignoring the caller's cardWidth/cardHeight and never growing with the
user's text-scale setting. With "Large" text, long values (e.g. a
root-folder path) overflowed the fixed card and were truncated. Honor
the provided cardWidth/cardHeight and multiply by the text scale
(new useTVRelativeScale) so the card grows in step with the font.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 15:27:59 +02:00
Fredrik Burmester
304cb06e0d fix(tv): use navigation option modal for advanced request selectors
The advanced request modal opened the quality-profile / root-folder /
request-as pickers as inline TVOptionSelector overlays (visible-prop),
which stacked on top of the modal and broke TV focus. Switch them to the
navigation-based useTVOptionModal().showOptions pattern (the same one
settings uses) so each picker is its own route with proper focus, and
returns to the request modal on select. Removes the activeSelector state
and the inline selectors.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 12:05:36 +02:00
Fredrik Burmester
11d71af468 fix(tv): replace season sheet with advanced request modal, not stack it
When a user has advanced-request permission, the season-select sheet
opened the advanced request modal via router.back() + push(). back() is
batched, so the push landed first and the advanced sheet stacked on top
of the season sheet, breaking focus. Add a `replace` option to
showRequestModal and use it here so the advanced modal takes the season
sheet's place in the stack.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:57:26 +02:00
Fredrik Burmester
01fd552a0c fix(tv): match season-select card font sizes to other TV sheets
The season title used typography.body (40) — larger than the sheet's own
heading — and the episode count used callout. Other TV cards/sheets use
callout for the primary label and callout-4 for secondary text. Align the
season card to that convention.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:49:37 +02:00
Fredrik Burmester
427e70e7ef fix(tv): stop season-select card content from collapsing (flex:1)
The season info container used flex:1 inside the card, but the card is
an auto-height column — flex:1 (flexBasis:0) collapsed the box to ~0
height, so the season number and episode count never had room to render
(only the explicitly-sized checkmark/status icons showed). Size the
container to its content instead.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:46:18 +02:00
Fredrik Burmester
d8fcb801e1 fix(tv): scale season-select card text so season info is visible
The TV "Select seasons" request sheet rendered each card's season
number and episode count via the common Text component, which has no
default font size — so they fell back to ~14px and were effectively
invisible on a TV (only the checkmark and status badge showed). Use the
scaled TV typography (body/callout) for the card text and widen the card
(scaleSize) so the larger labels fit.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:03:23 +02:00
Fredrik Burmester
913bd9b1da fix(tv): add top padding so search filter badges aren't clipped
The filter badges are the first row in the results ScrollView; their
focus-scale animation and shadow extended above the ScrollView's top
edge and got clipped. Add paddingTop to the content container.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:20:01 +02:00
Fredrik Burmester
f33c777e0c fix(tv): align search page edges to the app-wide horizontal padding
The TV search/discover page had three competing left-edge paddings:
the Library/Discover badges used HORIZONTAL_PADDING (60), the Jellyseerr
discover sections used SCALE_PADDING (20), while the rest of the app
(home rows, library sections, loading skeleton) uses
sizes.padding.horizontal. This left the filter badges visibly
misaligned with the content grid below them.

Unify the badges and the Jellyseerr discover/search sections onto
sizes.padding.horizontal so the filter row, section headers, and posters
share one edge — consistent with the home and library screens.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:12:19 +02:00
Fredrik Burmester
eba08b412f docs(learned-facts): useNetworkAwareQueryClient now Proxy-based
The hook was rewritten from Object.create to a Proxy that binds
non-invalidateQueries methods to the real client, so removeQueries /
setQueryData / getQueriesData work through it now. Update the fact to
reflect this (discovered while adding removeQueries to clearAllJellyseerData).

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:48:19 +02:00
Fredrik Burmester
bbef84132b feat(tv): add Jellyseerr connect support on TV (incl. Apple TV)
Adds the missing piece for Jellyseerr on TV: a way to configure and
connect to a Jellyseerr server from the TV settings screen. The discover
and search UI, native tvOS search field, and post-login auto-connect
already existed on develop, but there was no TV-side connect/disconnect
flow — so seerr could never be enabled on Apple TV.

- settings.tv.tsx: new "seerr" section with server URL + password inputs
  and Connect/Disconnect (respects plugin-locked server URLs)
- useJellyseerr: add validateJellyseerrSession(); clear cached search
  results on disconnect
- search: prompt to connect when a server is configured but no session
  exists, and warn when the session has expired on Discover
- translations: add connect/session keys to en + sv

All additions are platform-agnostic React Native, so they work on both
Apple TV and Android TV. Ported from #1676 (which was 40 commits behind
develop and conflicting); the unrelated Android tv-recommendations
changes from that PR were intentionally left out.

Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:47:23 +02:00
lance chant
cc0007926d fix: fixing some performance issues and mpv upgrade (#1765)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-28 17:35:00 +02:00
renovate[bot]
9e29305e28 chore(deps): Update dependency lint-staged to v17.0.8 (#1771)
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
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (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
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (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 / 🚑 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-25 20:27:25 +02:00
renovate[bot]
ae9c05637b chore(deps): Update actions/cache action to v6 (#1770)
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
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-25 13:30:54 +02:00
renovate[bot]
f820bedf6e chore(deps): Update CI dependencies (minor) (#1768) 2026-06-25 11:41:42 +02:00
bbijsterbosch
47c5d61f28 fix: Roadmap link in README (#1524)
Co-authored-by: Gauvain <contact@uruk.dev>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-25 10:45:50 +02:00
lance chant
517bc7bbb5 feat: android tv menu (#1709)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-25 09:08:12 +02:00
Niyazaki
b256e99fc8 fix(search): set typed text color on Android search bar (#1756)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (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
🚦 Security & Quality Gate / 📝 Validate PR Title (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 / 🔍 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-23 09:11:38 +02:00
Gauvain
e660b98871 fix(mpv): force software decoding on Android emulator (#1752)
Some checks failed
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (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 tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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 (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-19 00:12:40 +02:00
46 changed files with 1349 additions and 481 deletions

View File

@@ -6,4 +6,6 @@
## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
**Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.

View File

@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -79,7 +79,7 @@ jobs:
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -157,7 +157,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -179,7 +179,7 @@ jobs:
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -244,7 +244,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -316,7 +316,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -383,7 +383,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -453,7 +453,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache

View File

@@ -114,7 +114,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# renovate: datasource=node-version depName=node versioning=node
node-version: "24.17.0"
node-version: "24.18.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

@@ -77,7 +77,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin

View File

@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr connection state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,72 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.password")}
value={jellyseerrPassword}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting")
: t("common.connect")
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected")
: t("common.not_connected")
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
@@ -20,7 +25,13 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -106,8 +120,40 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
// Prompt the user to connect when a Jellyseerr server is configured but no
// session exists yet (only once per focus, and only while the tab is focused).
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr"),
t("jellyseerr.connect_in_settings"),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate the Jellyseerr session when switching to Discover; warn if expired.
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired"),
t("jellyseerr.session_expired_connect_again"),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
@@ -305,6 +351,8 @@ export default function SearchPage() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: color of the user-typed text (was dark and unreadable on the dark header)
textColor: "#fff",
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",

View File

@@ -3,16 +3,24 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import { Stack, useSegments, withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import useRouter from "@/hooks/useAppRouter";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap
>(Navigator);
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
function TVTabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
const segments = useSegments();
const router = useRouter();
const currentTab = segments.find(isTabRoute);
const lastSegment = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo(
() =>
[
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
: { key: "(custom-links)", label: t("tabs.custom_links") },
{ key: "(settings)", label: t("tabs.settings") },
].filter((tab): tab is TVNavBarTab => tab !== null),
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.showCustomMenuLinks,
t,
],
);
const activeTabKey = currentTab ?? "(home)";
const visibleKeys = useMemo(
() => new Set(tabs.map((tab) => tab.key)),
[tabs],
);
const handleTabChange = useCallback(
(key: string) => {
if (key === currentTab) return;
if (key === "(home)") eventBus.emit("scrollToTop");
if (key === "(search)") eventBus.emit("searchTabPressed");
router.replace(`/(auth)/(tabs)/${key}`);
},
[currentTab, router],
);
const navigateHome = useCallback(() => {
router.replace("/(auth)/(tabs)/(home)");
}, [router]);
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
// If current tab is no longer visible (setting changed), navigate to home
useEffect(() => {
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
router.replace("/(auth)/(tabs)/(home)");
}
}, [visibleKeys, activeTabKey, router]);
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<Stack
screenOptions={{ headerShown: false, animation: "none" }}
initialRouteName='(home)'
>
<Stack.Screen name='index' redirect />
</Stack>
<TVNavBar
tabs={tabs}
activeTabKey={activeTabKey}
onTabChange={handleTabChange}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
</View>
);
}
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
// Must be called before any conditional return (rules of hooks)
useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />

View File

@@ -456,10 +456,23 @@ export default function DirectPlayerPage() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.pause();
// Synchronously destroy the mpv instance + decoder + surface buffers
// BEFORE the screen unmounts. Otherwise the next screen (or the next
// episode's player) mounts while the old 4K decoder is still alive,
// causing OOM on low-RAM devices. Native stop() is idempotent so the
// later React unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
// Pre-libmpv-1.0 used `stop()`:
// videoRef.current?.stop();
revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
// Release the keep-awake wakelock acquired during playback so it
// doesn't follow us back to the home screen and block the TV
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
// and only released on the "paused" event; without this, navigating
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
@@ -1105,6 +1118,15 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// Destroy the current mpv instance BEFORE navigating so the old 4K
// decoder + surface buffers are freed before the new player screen
// mounts. Without this, Expo Router briefly holds two simultaneous
// mpv instances during the transition (~768 MB of surface buffers
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
// devices. Native stop() is idempotent so the subsequent React
// unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
@@ -1115,6 +1137,7 @@ export default function DirectPlayerPage() {
bitrateValue,
router,
isPlaybackStopped,
videoRef,
]);
// Apply subtitle settings when video loads

View File

@@ -11,7 +11,10 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
useScaledTVTypography,
useTVRelativeScale,
} from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
@@ -22,6 +25,7 @@ export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const relativeScale = useTVRelativeScale();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
@@ -97,8 +101,15 @@ export default function TVOptionModal() {
}
const { title, options } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
// Honor the caller-provided card size (e.g. wider cards for long root-folder
// paths) and grow it in step with the user's text-scale setting so larger
// fonts don't get clipped.
const scaledCardWidth = scaleSize(
(modalState.cardWidth ?? 160) * relativeScale,
);
const scaledCardHeight = scaleSize(
(modalState.cardHeight ?? 75) * relativeScale,
);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>

View File

@@ -15,11 +15,12 @@ import {
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import { TVButton } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
@@ -35,6 +36,7 @@ export default function TVRequestModalPage() {
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showOptions } = useTVOptionModal();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
@@ -43,10 +45,6 @@ export default function TVRequestModalPage() {
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
@@ -242,17 +240,14 @@ export default function TVRequestModalPage() {
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
@@ -353,18 +348,37 @@ export default function TVRequestModalPage() {
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
onPress={() =>
showOptions({
title: t("jellyseerr.quality_profile"),
options: qualityProfileOptions,
onSelect: handleProfileChange,
})
}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
onPress={() =>
showOptions({
title: t("jellyseerr.root_folder"),
options: rootFolderOptions,
onSelect: handleFolderChange,
cardWidth: 280,
})
}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
onPress={() =>
showOptions({
title: t("jellyseerr.request_as"),
options: userOptions,
onSelect: handleUserChange,
})
}
/>
{tagItems.length > 0 && (
@@ -409,33 +423,6 @@ export default function TVRequestModalPage() {
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}

View File

@@ -26,6 +26,7 @@ import {
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
@@ -49,6 +50,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -119,7 +121,10 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
<Text
style={[
styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" },
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
numberOfLines={1}
>
@@ -132,6 +137,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
style={[
styles.episodeCount,
{
fontSize: typography.callout - 4,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
@@ -251,14 +257,15 @@ export default function TVSeasonSelectModalPage() {
};
if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal
router.back();
// Replace this sheet with the advanced request modal so it takes our
// place in the stack instead of stacking on top (which breaks focus).
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
replace: true,
});
return;
}
@@ -401,7 +408,7 @@ const styles = StyleSheet.create({
gap: 16,
},
seasonCard: {
width: 160,
width: scaleSize(220),
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
@@ -415,7 +422,10 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
seasonInfo: {
flex: 1,
// Note: no `flex: 1` here — the card is an auto-height column, so flex:1
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
// its content instead.
alignSelf: "stretch",
},
seasonTitle: {
fontWeight: "600",
@@ -426,9 +436,7 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {
fontSize: 14,
},
episodeCount: {},
statusBadge: {
width: 22,
height: 22,

View File

@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
@@ -100,6 +101,22 @@ SplashScreen.setOptions({
fade: true,
});
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
try {
Image.configureCache({
maxMemoryCost: Platform.isTV
? 8 * 1024 * 1024 // ~8 MB on TV
: 128 * 1024 * 1024, // ~128 MB on mobile
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
});
} catch {
// configureCache is a no-op on some platforms/versions; safe to ignore.
}
function useNotificationObserver() {
const router = useRouter();

View File

@@ -111,7 +111,7 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "6.0.3",
},
@@ -1270,7 +1270,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],

View File

@@ -140,9 +140,11 @@ export const Home = () => {
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
// decoded ARGB) is too large to pin in the memory cache on every
// focus change. Disk cache is fast enough for a 500ms crossfade.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}

View File

@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
initialNumToRender={4}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}

View File

@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
let isCancelled = false;
const performCrossfade = async () => {
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
// out of the memory cache avoids bloat when the user cycles through
// hero items quickly.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}
@@ -379,7 +382,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const tvosTopPadding = scaleSize(145);
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -166,6 +167,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -238,7 +240,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{slideTitle}
@@ -249,7 +251,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}

View File

@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -233,6 +234,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -243,7 +245,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -254,7 +256,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
@@ -285,6 +287,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -295,7 +298,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -306,7 +309,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
@@ -337,6 +340,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -347,7 +351,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -358,7 +362,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}

View File

@@ -22,7 +22,6 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
@@ -163,6 +162,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
discoverSliders,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginHorizontal: sizes.padding.horizontal,
marginBottom: 24,
}}
>
@@ -280,12 +280,15 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
// Top padding so the focus-scale/shadow on the first row (filter
// badges) isn't clipped against the ScrollView's top edge.
paddingTop: 16,
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}

155
components/tv/TVNavBar.tsx Normal file
View File

@@ -0,0 +1,155 @@
import React from "react";
import {
Animated,
Pressable,
ScrollView,
StyleProp,
View,
ViewStyle,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVPadding } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVNavBarTab {
key: string;
label: string;
}
export interface TVNavBarProps {
tabs: TVNavBarTab[];
activeTabKey: string;
onTabChange: (key: string) => void;
style?: StyleProp<ViewStyle>;
}
const TVNavBarTabItem: React.FC<{
label: string;
isActive: boolean;
onSelect: () => void;
onLayout: (e: {
nativeEvent: { layout: { x: number; width: number } };
}) => void;
hasTVPreferredFocus: boolean;
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const bg = focused
? "rgba(255, 255, 255, 0.95)"
: isActive
? "rgba(255, 255, 255, 0.15)"
: "transparent";
const textColor = focused
? "#000"
: isActive
? "#fff"
: "rgba(255, 255, 255, 0.7)";
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
onLayout={onLayout}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: bg,
borderRadius: scaleSize(24),
borderWidth: isActive && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.3)",
paddingHorizontal: scaleSize(28),
paddingVertical: scaleSize(14),
},
]}
>
<Text
style={{
fontSize: typography.heading,
color: textColor,
fontWeight: isActive || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVNavBar: React.FC<TVNavBarProps> = ({
tabs,
activeTabKey,
onTabChange,
style,
}) => {
const scrollRef = React.useRef<ScrollView>(null);
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
{},
);
const insets = useSafeAreaInsets();
const handleTabLayout = React.useCallback(
(key: string) =>
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
tabLayouts.current[key] = e.nativeEvent.layout;
},
[],
);
const handleTabChange = React.useCallback(
(key: string) => {
onTabChange(key);
const layout = tabLayouts.current[key];
if (layout && scrollRef.current) {
scrollRef.current.scrollTo({
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
animated: true,
});
}
},
[onTabChange],
);
if (tabs.length === 0) return null;
return (
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
gap: scaleSize(12),
}}
>
{tabs.map((tab) => (
<TVNavBarTabItem
key={tab.key}
label={tab.label}
isActive={tab.key === activeTabKey}
onSelect={() => handleTabChange(tab.key)}
onLayout={handleTabLayout(tab.key)}
hasTVPreferredFocus={tab.key === activeTabKey}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<Image
placeholder={{ blurhash }}
key={item.Id}
id={item.Id}
source={{ uri: imageUrl }}
recyclingKey={item.Id}
cachePolicy='memory-disk'
contentFit='cover'
style={{

View File

@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";

View File

@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.cacheSeconds !== undefined && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
{info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
")"
: ""}
</Text>
)}
{info?.voDriver && (
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames

View File

@@ -55,6 +55,20 @@ export type ScaledTVTypography = {
callout: number;
};
/**
* Returns the user's text-scale factor relative to the Default scale (1.0 at
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
* (e.g. option-card width/height) in step with the scaled font so larger text
* settings don't overflow fixed boxes.
*/
export const useTVRelativeScale = (): number => {
const { settings } = useSettings();
const scale =
scaleMultipliers[settings.tvTypographyScale] ??
scaleMultipliers[TVTypographyScale.Default];
return scale / scaleMultipliers[TVTypographyScale.Default];
};
/**
* Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling.

View File

@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
/**
* Checks whether the persisted Jellyseerr session (user + cookies) is still
* valid by hitting the server status endpoint. Clears local session data if the
* request fails (expired/revoked cookie).
*/
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
}, []);
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -4,41 +4,42 @@ import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
useTVBackPress,
} from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress";
/** All tab route names used in the bottom tab navigator. */
export const TAB_ROUTES = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(custom-links)",
"(settings)",
] as const;
export type TabRoute = (typeof TAB_ROUTES)[number];
/** Check if a segment string is a tab route. */
export function isTabRoute(s: string): s is TabRoute {
return (TAB_ROUTES as readonly string[]).includes(s);
}
/**
* Check if we're at the root of a tab
*/
function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1];
const tabNames = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(settings)",
"(custom-links)",
];
return tabNames.includes(lastSegment) || lastSegment === "index";
return isTabRoute(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
function getCurrentTab(segments: string[]): string | undefined {
return segments.find(
(s) =>
s === "(home)" ||
s === "(search)" ||
s === "(favorites)" ||
s === "(libraries)" ||
s === "(watchlists)" ||
s === "(settings)" ||
s === "(custom-links)",
);
function getCurrentTab(segments: string[]): TabRoute | undefined {
return segments.find(isTabRoute);
}
/**
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}
/**
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
*
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
* built-in tab-level back handling — pressing back at a tab root would pop the
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
* to Home instead.
*/
export function useTVTabRootBackHandler(
onNavigateHome: () => void,
isAtTabRoot: boolean,
currentTab: string | undefined,
) {
useTVBackPress(() => {
if (!Platform.isTV || Platform.OS !== "android") return false;
if (!isAtTabRoot || currentTab === "(home)") return false;
onNavigateHome();
return true;
}, [isAtTabRoot, currentTab, onNavigateHome]);
}

View File

@@ -11,6 +11,12 @@ interface ShowRequestModalParams {
id: number;
mediaType: MediaType;
onRequested: () => void;
/**
* Replace the current route instead of pushing. Use when opening the request
* modal from another modal (e.g. the season selector) so the new sheet takes
* its place rather than stacking on top of it (which breaks TV focus).
*/
replace?: boolean;
}
export const useTVRequestModal = () => {
@@ -25,7 +31,11 @@ export const useTVRequestModal = () => {
mediaType: params.mediaType,
onRequested: params.onRequested,
});
router.push("/(auth)/tv-request-modal");
if (params.replace) {
router.replace("/(auth)/tv-request-modal");
} else {
router.push("/(auth)/tv-request-modal");
}
},
[router],
);

View File

@@ -53,5 +53,5 @@ android {
dependencies {
// libmpv from Maven Central
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
}

View File

@@ -3,13 +3,14 @@ package expo.modules.mpvplayer
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.content.res.AssetManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.system.Os
import android.util.Log
import android.view.Surface
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
/**
* MPV renderer that wraps libmpv for video playback.
@@ -35,6 +36,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
/**
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
* → ANR. We force software decoding here.
*
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
* false positive would needlessly drop shipping hardware to software decoding. The
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
* emulator build fingerprint, none of which appear on real devices.
*/
private fun isEmulator(): Boolean {
val hardware = Build.HARDWARE.lowercase()
if (hardware == "goldfish" || hardware == "ranchu") return true
val product = Build.PRODUCT
if (product == "sdk" || product.startsWith("sdk_")) return true
val fingerprint = Build.FINGERPRINT
return fingerprint.startsWith("generic") ||
fingerprint.contains("emulator", ignoreCase = true)
}
interface Delegate {
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean)
@@ -51,8 +76,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
private var surface: Surface? = null
private var isRunning = false
private var isStopping = false
// This renderer's own mpv handle. Per-instance (not singleton) — each
// player screen gets a fresh mpv handle and drops the reference on stop.
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
// so we mirror Findroid and let the JVM GC + native finalization path
// reclaim resources. Only one player is alive at a time in this app.
private var mpv: MPVLib? = null
// Cached state
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
@@ -112,100 +144,108 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun start(voDriver: String = "gpu-next") {
if (isRunning) return
try {
MPVLib.create(context)
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
// Per-instance handle — see class-level comment. Each player gets
// its own mpv; we drop the reference in stop().
val mpv = MPVLib.create(context)
this.mpv = mpv
mpv.addObserver(this)
// Resolved once — TV gets the memory-pressure customizations
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
// audio-buffer) that would be counterproductive on higher-RAM
// mobile devices. Demuxer cache sizes are NOT included here —
// those come from user settings via load().
val isTV = isTvDevice()
// mpv config directory — used by the config-dir option below and
// as XDG_CONFIG_HOME for fontconfig.
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))
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
// persists its font index across runs instead of re-walking
// /system/fonts on every subtitle/seek event. Each rebuild costs
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
// holds onto. Without this we see "No usable fontconfig
// configuration file found, using fallback" on every re-init.
try {
val cacheDir = context.cacheDir.absolutePath
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
Os.setenv("XDG_CONFIG_HOME", configDir, true)
Os.setenv("HOME", configDir, true)
} catch (e: Exception) {
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
}
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
mpv?.setOptionString("config", "yes")
mpv?.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid)
this.voDriver = voDriver
MPVLib.setOptionString("vo", voDriver)
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
mpv?.setOptionString("vo", voDriver)
mpv?.setOptionString("gpu-context", "android")
mpv?.setOptionString("opengl-es", "yes")
// Hardware video decoding
// TV: zero-copy (mediacodec) for better performance on low-power devices
// Mobile: copy mode (mediacodec-copy) for better compatibility
val isTV = isTvDevice()
if (isTV) {
MPVLib.setOptionString("hwdec", "mediacodec")
MPVLib.setOptionString("profile", "fast")
} else {
MPVLib.setOptionString("hwdec", "mediacodec-copy")
// Hardware decoder codecs (shared)
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
// Pause on initial cache fill (shared default). The actual
// cache mode, cache-secs, and demuxer cache sizes come from
// user preferences and are applied per-load in load().
mpv?.setOptionString("cache-pause-initial", "yes")
// Hardware decode path + TV-only memory options. Demuxer cache
// sizes and cache-secs are NOT set here — they come from user
// preferences via load().
// - Emulator: software decode. Its MediaCodec can't bind an
// output surface (surface 0x0); HEVC then fails cleanly and
// mpv auto-falls-back to software, but H.264 "opens"
// deceptively and wedges the core with no fallback (black
// video, then any command — seek/pause — deadlocks the UI
// thread → ANR). hwdec=no makes every codec render via the
// gpu-next VO. Real devices unaffected.
// - Real TV hardware: zero-copy `mediacodec` (fastest on
// low-power devices) + fast profile.
// - Real phone: `mediacodec-copy` (broadest compatibility).
when {
isEmulator() -> mpv?.setOptionString("hwdec", "no")
isTV -> {
mpv?.setOptionString("hwdec", "mediacodec")
mpv?.setOptionString("profile", "fast")
// Don't retain already-played content for backward
// seeking over a network source — Jellyfin can re-fetch
// on demand. Saves up to ~30 MiB on long seeks and
// reduces swap pressure.
mpv?.setOptionString("demuxer-seekable-cache", "no")
// Larger audio buffer to absorb page-fault stalls
// (default ~0.2s). Cheap insurance against the audio
// underruns that happen when the kernel is swap-thrashing.
mpv?.setOptionString("audio-buffer", "0.5")
}
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
}
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
// Cache settings for better network streaming
MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-pause-initial", "yes")
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
MPVLib.setOptionString("demuxer-readahead-secs", "20")
// Seeking optimization - faster seeking at the cost of less precision
// Use keyframe seeking by default (much faster for network streams)
MPVLib.setOptionString("hr-seek", "no")
mpv?.setOptionString("hr-seek", "no")
// Drop frames during seeking for faster response
MPVLib.setOptionString("hr-seek-framedrop", "yes")
mpv?.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes")
mpv?.setOptionString("sub-scale-with-window", "no")
mpv?.setOptionString("sub-use-margins", "no")
mpv?.setOptionString("subs-match-os-language", "yes")
mpv?.setOptionString("subs-fallback", "yes")
// Important: Start with force-window=no, will be set to yes when surface is attached
MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("keep-open", "always")
MPVLib.initialize()
mpv?.setOptionString("force-window", "no")
mpv?.setOptionString("keep-open", "always")
mpv.initialize()
// Observe properties
observeProperties()
@@ -218,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
fun stop() {
if (isStopping) return
if (!isRunning) return
isStopping = true
isRunning = false
try {
MPVLib.removeObserver(this)
MPVLib.detachSurface()
MPVLib.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error stopping MPV: ${e.message}")
}
isStopping = false
val m = mpv
mpv = null
// Clear cached media state on the main thread so the next player
// screen doesn't observe stale position/duration values during the
// (async) teardown below.
currentUrl = null
currentHeaders = null
pendingExternalSubtitles = emptyList()
initialSubtitleId = null
initialAudioId = null
cachedPosition = 0.0
cachedDuration = 0.0
cachedCacheSeconds = 0.0
if (m == null) return
// Teardown runs on a background daemon thread. mpv's "stop" command
// flushes the demuxer queue and releases the MediaCodec hardware
// decoder — synchronous JNI work that can block for hundreds of ms
// on TV hardware. Running it on the main thread produced a visible
// delay/stutter between pressing "exit" and the confirm alert
// appearing. The local `m` keeps the MPVLib instance alive for the
// lifetime of this thread even though we've already nulled `mpv`.
Thread {
// Drop force-window BEFORE issuing stop. With keep-open=always +
// force-window=yes, mpv tears down the decoder at stop time but
// tries to keep the VO alive — which fires an internal
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
// reconfig path crashes with "Missing surface pointer" because we
// detach the Surface below before mpv's worker reaches the
// reconfig step (command() is async). Setting force-window=no
// first makes mpv tear VO down cleanly instead of attempting a
// doomed re-init, eliminating the fatal VO error and the
// "playback won't restart" aftermath.
try {
m.setOptionString("force-window", "no")
} catch (e: Exception) {
Log.e(TAG, "Error clearing force-window: ${e.message}")
}
try {
// Stop playback — flushes demuxer queue and signals MediaCodec
// to release its hardware decoders. This is the bulk of what
// we can reclaim without calling destroy().
m.command(arrayOf("stop"))
} catch (e: Exception) {
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
}
try {
m.removeObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Error removing mpv observer: ${e.message}")
}
try {
m.detachSurface()
} catch (e: Exception) {
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
}
}.also { it.isDaemon = true }.start()
}
/**
@@ -247,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) {
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
mpv?.attachSurface(surface)
mpv?.setOptionString("force-window", "yes")
// Read back vo to confirm it's still active
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
}
}
@@ -270,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) {
MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
mpv?.detachSurface()
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
}
}
@@ -282,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
*/
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
mpv?.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
@@ -298,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
mpv?.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
}
}
@@ -310,29 +397,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
startPosition: Double? = null,
externalSubtitles: List<String>? = null,
initialSubtitleId: Int? = null,
initialAudioId: Int? = null
initialAudioId: Int? = null,
cacheEnabled: String? = null,
cacheSeconds: Int? = null,
demuxerMaxBytes: Int? = null,
demuxerMaxBackBytes: Int? = null
) {
currentUrl = url
currentHeaders = headers
pendingExternalSubtitles = externalSubtitles ?: emptyList()
this.initialSubtitleId = initialSubtitleId
this.initialAudioId = initialAudioId
_isLoading = true
isReadyToSeek = false
mainHandler.post { delegate?.onLoadingChanged(true) }
// Stop previous playback
MPVLib.command(arrayOf("stop"))
mpv?.command(arrayOf("stop"))
// Set HTTP headers if provided
updateHttpHeaders(headers)
// Apply cache/buffer settings from user preferences (mirrors iOS).
// These override the conservative defaults applied in start() so the
// TV/mobile settings screen actually takes effect on Android.
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
// Set start position
// Set start position. mpv's time parser requires '.' as the decimal
// separator; use Locale.US so devices with other default locales
// (e.g. ',' as decimal separator) don't break resume-from-position.
if (startPosition != null && startPosition > 0) {
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
} else {
MPVLib.setPropertyString("start", "0")
mpv?.setPropertyString("start", "0")
}
// Set initial audio track if specified
@@ -352,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
// Load the file
MPVLib.command(arrayOf("loadfile", url, "replace"))
mpv?.command(arrayOf("loadfile", url, "replace"))
}
fun reloadCurrentItem() {
@@ -368,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
MPVLib.setPropertyString("http-header-fields", headerString)
mpv?.setPropertyString("http-header-fields", headerString)
}
private fun observeProperties() {
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
}
// MARK: - Playback Controls
fun play() {
MPVLib.setPropertyBoolean("pause", false)
mpv?.setPropertyBoolean("pause", false)
}
fun pause() {
MPVLib.setPropertyBoolean("pause", true)
mpv?.setPropertyBoolean("pause", true)
}
fun togglePause() {
@@ -400,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun seekTo(seconds: Double) {
val clamped = maxOf(0.0, seconds)
cachedPosition = clamped
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
}
fun seekBy(seconds: Double) {
val newPosition = maxOf(0.0, cachedPosition + seconds)
cachedPosition = newPosition
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
}
fun setSpeed(speed: Double) {
_playbackSpeed = speed
MPVLib.setPropertyDouble("speed", speed)
mpv?.setPropertyDouble("speed", speed)
}
fun getSpeed(): Double {
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
}
// MARK: - Subtitle Controls
@@ -423,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getSubtitleTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "sub") continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
@@ -447,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setSubtitleTrack(trackId: Int) {
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
if (trackId < 0) {
MPVLib.setPropertyString("sid", "no")
mpv?.setPropertyString("sid", "no")
} else {
MPVLib.setPropertyInt("sid", trackId)
mpv?.setPropertyInt("sid", trackId)
}
}
fun disableSubtitles() {
MPVLib.setPropertyString("sid", "no")
mpv?.setPropertyString("sid", "no")
}
fun getCurrentSubtitleTrack(): Int {
return MPVLib.getPropertyInt("sid") ?: 0
return mpv?.getPropertyInt("sid") ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached"
MPVLib.command(arrayOf("sub-add", url, flag))
mpv?.command(arrayOf("sub-add", url, flag))
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
MPVLib.setPropertyInt("sub-pos", position)
mpv?.setPropertyInt("sub-pos", position)
}
fun setSubtitleScale(scale: Double) {
MPVLib.setPropertyDouble("sub-scale", scale)
mpv?.setPropertyDouble("sub-scale", scale)
}
fun setSubtitleMarginY(margin: Int) {
MPVLib.setPropertyInt("sub-margin-y", margin)
mpv?.setPropertyInt("sub-margin-y", margin)
}
fun setSubtitleAlignX(alignment: String) {
MPVLib.setPropertyString("sub-align-x", alignment)
mpv?.setPropertyString("sub-align-x", alignment)
}
fun setSubtitleAlignY(alignment: String) {
MPVLib.setPropertyString("sub-align-y", alignment)
mpv?.setPropertyString("sub-align-y", alignment)
}
fun setSubtitleFontSize(size: Int) {
MPVLib.setPropertyInt("sub-font-size", size)
mpv?.setPropertyInt("sub-font-size", size)
}
fun setSubtitleBorderStyle(style: String) {
MPVLib.setPropertyString("sub-border-style", style)
mpv?.setPropertyString("sub-border-style", style)
}
fun setSubtitleBackgroundColor(color: String) {
MPVLib.setPropertyString("sub-back-color", color)
mpv?.setPropertyString("sub-back-color", color)
}
fun setSubtitleAssOverride(mode: String) {
MPVLib.setPropertyString("sub-ass-override", mode)
mpv?.setPropertyString("sub-ass-override", mode)
}
// MARK: - Audio Track Controls
@@ -509,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getAudioTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "audio") continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
if (channels != null && channels > 0) {
track["channels"] = channels
}
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
@@ -538,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setAudioTrack(trackId: Int) {
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
MPVLib.setPropertyInt("aid", trackId)
mpv?.setPropertyInt("aid", trackId)
}
fun getCurrentAudioTrack(): Int {
return MPVLib.getPropertyInt("aid") ?: 0
return mpv?.getPropertyInt("aid") ?: 0
}
// MARK: - Video Scaling
@@ -551,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
val panscanValue = if (zoomed) 1.0 else 0.0
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
MPVLib.setPropertyDouble("panscan", panscanValue)
mpv?.setPropertyDouble("panscan", panscanValue)
}
// MARK: - Technical Info
@@ -560,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val info = mutableMapOf<String, Any>()
// Video dimensions
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
info["videoWidth"] = it
}
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
info["videoHeight"] = it
}
// Video codec
MPVLib.getPropertyString("video-format")?.let {
mpv?.getPropertyString("video-format")?.let {
info["videoCodec"] = it
}
// Audio codec
MPVLib.getPropertyString("audio-codec-name")?.let {
mpv?.getPropertyString("audio-codec-name")?.let {
info["audioCodec"] = it
}
// FPS (container fps)
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
info["fps"] = it
}
// Video bitrate (bits per second)
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
info["videoBitrate"] = it
}
// Audio bitrate (bits per second)
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
info["audioBitrate"] = it
}
// Demuxer cache duration (seconds of video buffered)
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
info["cacheSeconds"] = it
}
// Configured cache limits — read back from mpv to confirm user
// settings actually took effect. mpv stores byte sizes as int64
// (bytes); convert to MiB for display.
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
}
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
}
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
info["cacheSecsLimit"] = secs
}
// Dropped frames
MPVLib.getPropertyInt("frame-drop-count")?.let {
mpv?.getPropertyInt("frame-drop-count")?.let {
info["droppedFrames"] = it
}
// Active video output driver (read from MPV to confirm what's actually applied)
MPVLib.getPropertyString("vo")?.let {
mpv?.getPropertyString("vo")?.let {
info["voDriver"] = it
}
// Active hardware decoder
MPVLib.getPropertyString("hwdec-active")?.let {
// Active hardware decoder.
// hwdec-current yields e.g. "mediacodec",
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
mpv?.getPropertyString("hwdec-current")?.let {
info["hwdec"] = it
}
// Estimated video output fps (renderer-side, after filtering).
// Useful for diagnosing display/pipeline drops vs container fps.
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
info["estimatedVfFps"] = it
}
return info
}
@@ -704,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
}
pendingExternalSubtitles = emptyList()
}

View File

@@ -1,20 +1,29 @@
package expo.modules.mpvplayer
import android.content.Context
import android.util.Log
import android.view.Surface
import dev.jdtech.mpv.MPVLib as LibMPV
/**
* Wrapper around the dev.jdtech.mpv.MPVLib class.
* This provides a consistent interface for the rest of the app.
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
*
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
* a fresh, independent handle. Each player creates its own MPVLib instance
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
* call `LibMPV.destroy()` — its native implementation has an internal
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
* GC reach the JVM-level finalizer (or never reaching it, since the native
* handle lives in process-global state until exit) is strictly safer than
* crashing.
*
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
* stays allocated until the next player's allocation displaces it in scudo's
* arena. On a TV app where the player is the dominant memory consumer and
* only one player is alive at a time, this is acceptable.
*/
object MPVLib {
private const val TAG = "MPVLib"
private var initialized = false
// Event observer interface
class MPVLib private constructor(private val instance: LibMPV) {
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
interface EventObserver {
fun eventProperty(property: String)
fun eventProperty(property: String, value: Long)
@@ -23,198 +32,144 @@ object MPVLib {
fun eventProperty(property: String, value: Double)
fun event(eventId: Int)
}
private val observers = mutableListOf<EventObserver>()
// Library event observer that forwards to our observers
// Library event observer that forwards LibMPV callbacks to our observers.
private val libObserver = object : LibMPV.EventObserver {
override fun eventProperty(property: String) {
override fun eventProperty(property: String) =
dispatch { it.eventProperty(property) }
override fun eventProperty(property: String, value: Long) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: Boolean) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: String) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: Double) =
dispatch { it.eventProperty(property, value) }
override fun event(eventId: Int) =
dispatch { it.event(eventId) }
private inline fun dispatch(block: (EventObserver) -> Unit) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property)
}
}
}
override fun eventProperty(property: String, value: Long) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: Boolean) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: String) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: Double) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun event(eventId: Int) {
synchronized(observers) {
for (observer in observers) {
observer.event(eventId)
}
observers.forEach(block)
}
}
}
fun addObserver(observer: EventObserver) {
synchronized(observers) {
observers.add(observer)
}
synchronized(observers) { observers.add(observer) }
}
fun removeObserver(observer: EventObserver) {
synchronized(observers) {
observers.remove(observer)
}
synchronized(observers) { observers.remove(observer) }
}
// MPV Event IDs
const val MPV_EVENT_NONE = 0
const val MPV_EVENT_SHUTDOWN = 1
const val MPV_EVENT_LOG_MESSAGE = 2
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
const val MPV_EVENT_COMMAND_REPLY = 5
const val MPV_EVENT_START_FILE = 6
const val MPV_EVENT_END_FILE = 7
const val MPV_EVENT_FILE_LOADED = 8
const val MPV_EVENT_IDLE = 11
const val MPV_EVENT_TICK = 14
const val MPV_EVENT_CLIENT_MESSAGE = 16
const val MPV_EVENT_VIDEO_RECONFIG = 17
const val MPV_EVENT_AUDIO_RECONFIG = 18
const val MPV_EVENT_SEEK = 20
const val MPV_EVENT_PLAYBACK_RESTART = 21
const val MPV_EVENT_PROPERTY_CHANGE = 22
const val MPV_EVENT_QUEUE_OVERFLOW = 24
// End file reason
const val MPV_END_FILE_REASON_EOF = 0
const val MPV_END_FILE_REASON_STOP = 2
const val MPV_END_FILE_REASON_QUIT = 3
const val MPV_END_FILE_REASON_ERROR = 4
const val MPV_END_FILE_REASON_REDIRECT = 5
/**
* Create and initialize the MPV library
*/
fun create(context: Context, configDir: String? = null) {
if (initialized) return
try {
LibMPV.create(context)
LibMPV.addObserver(libObserver)
initialized = true
Log.i(TAG, "libmpv created successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to create libmpv: ${e.message}")
throw e
}
}
fun initialize() {
LibMPV.init()
instance.init()
}
fun destroy() {
if (!initialized) return
try {
LibMPV.removeObserver(libObserver)
LibMPV.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error destroying mpv: ${e.message}")
}
initialized = false
fun attachSurface(surface: android.view.Surface) {
instance.attachSurface(surface)
}
fun isInitialized(): Boolean = initialized
fun attachSurface(surface: Surface) {
LibMPV.attachSurface(surface)
}
fun detachSurface() {
LibMPV.detachSurface()
instance.detachSurface()
}
fun command(cmd: Array<String?>) {
LibMPV.command(cmd)
fun command(cmd: Array<String>) {
instance.command(cmd)
}
fun setOptionString(name: String, value: String): Int {
return LibMPV.setOptionString(name, value)
return instance.setOptionString(name, value)
}
fun getPropertyInt(name: String): Int? {
return try {
LibMPV.getPropertyInt(name)
} catch (e: Exception) {
null
}
}
fun getPropertyDouble(name: String): Double? {
return try {
LibMPV.getPropertyDouble(name)
} catch (e: Exception) {
null
}
}
fun getPropertyBoolean(name: String): Boolean? {
return try {
LibMPV.getPropertyBoolean(name)
} catch (e: Exception) {
null
}
}
fun getPropertyString(name: String): String? {
return try {
LibMPV.getPropertyString(name)
} catch (e: Exception) {
null
}
}
fun getPropertyInt(name: String): Int? = try {
instance.getPropertyInt(name)
} catch (e: Exception) { null }
fun getPropertyDouble(name: String): Double? = try {
instance.getPropertyDouble(name)
} catch (e: Exception) { null }
fun getPropertyBoolean(name: String): Boolean? = try {
instance.getPropertyBoolean(name)
} catch (e: Exception) { null }
fun getPropertyString(name: String): String? = try {
instance.getPropertyString(name)
} catch (e: Exception) { null }
fun setPropertyInt(name: String, value: Int) {
LibMPV.setPropertyInt(name, value)
instance.setPropertyInt(name, value)
}
fun setPropertyDouble(name: String, value: Double) {
LibMPV.setPropertyDouble(name, value)
instance.setPropertyDouble(name, value)
}
fun setPropertyBoolean(name: String, value: Boolean) {
LibMPV.setPropertyBoolean(name, value)
instance.setPropertyBoolean(name, value)
}
fun setPropertyString(name: String, value: String) {
LibMPV.setPropertyString(name, value)
instance.setPropertyString(name, value)
}
fun observeProperty(name: String, format: Int) {
LibMPV.observeProperty(name, format)
instance.observeProperty(name, format)
}
companion object {
/**
* Create a fresh mpv handle. Each call returns an independent instance —
* do not share across players. Attach exactly one [EventObserver] per
* player via [addObserver].
*/
fun create(context: Context): MPVLib {
val lib = LibMPV.create(context)
?: throw IllegalStateException("LibMPV.create returned null")
val wrapper = MPVLib(lib)
// The libObserver is attached for the lifetime of this MPVLib
// instance and forwards every LibMPV callback to our observers
// list. Player-specific observers are added/removed via
// addObserver/removeObserver.
lib.addObserver(wrapper.libObserver)
return wrapper
}
// MPV Event IDs (kept here so observers can reference them without
// holding a reference to an instance).
const val MPV_EVENT_NONE = 0
const val MPV_EVENT_SHUTDOWN = 1
const val MPV_EVENT_LOG_MESSAGE = 2
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
const val MPV_EVENT_COMMAND_REPLY = 5
const val MPV_EVENT_START_FILE = 6
const val MPV_EVENT_END_FILE = 7
const val MPV_EVENT_FILE_LOADED = 8
const val MPV_EVENT_IDLE = 11
const val MPV_EVENT_TICK = 14
const val MPV_EVENT_CLIENT_MESSAGE = 16
const val MPV_EVENT_VIDEO_RECONFIG = 17
const val MPV_EVENT_AUDIO_RECONFIG = 18
const val MPV_EVENT_SEEK = 20
const val MPV_EVENT_PLAYBACK_RESTART = 21
const val MPV_EVENT_PROPERTY_CHANGE = 22
const val MPV_EVENT_QUEUE_OVERFLOW = 24
// End file reason
const val MPV_END_FILE_REASON_EOF = 0
const val MPV_END_FILE_REASON_STOP = 2
const val MPV_END_FILE_REASON_QUIT = 3
const val MPV_END_FILE_REASON_ERROR = 4
const val MPV_END_FILE_REASON_REDIRECT = 5
}
}

View File

@@ -28,7 +28,11 @@ class MpvPlayerModule : Module() {
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
// Parse cache config if provided (mirrors iOS)
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
@Suppress("UNCHECKED_CAST")
val config = VideoLoadConfig(
url = urlString,
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
voDriver = source["voDriver"] as? String
voDriver = source["voDriver"] as? String,
cacheEnabled = cacheConfig?.get("enabled") as? String,
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
)
view.loadVideo(config)
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
view.pause()
}
// Stop playback and release the MediaCodec decoder + demuxer.
// Does not synchronously tear down the native mpv handle (see
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
// away from the player screen to avoid OOM during screen
// transitions on low-RAM devices.
AsyncFunction("destroy") { view: MpvPlayerView ->
view.destroy()
}
// Async function to seek to position
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
view.seekTo(position)

View File

@@ -26,7 +26,11 @@ data class VideoLoadConfig(
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null,
val voDriver: String? = null
val voDriver: String? = null,
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null
)
/**
@@ -60,6 +64,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
@@ -131,6 +136,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
rendererStarted = true
pendingSurface?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface)
pendingSurface = null
}
@@ -149,6 +155,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
surfaceReady = true
if (rendererStarted) {
// Release the previous wrapper Surface before losing the only
// reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface
renderer?.attachSurface(surface)
} else {
pendingSurface = surface
@@ -207,7 +218,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
startPosition = config.startPosition,
externalSubtitles = config.externalSubtitles,
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId
initialAudioId = config.initialAudioId,
cacheEnabled = config.cacheEnabled,
cacheSeconds = config.cacheSeconds,
demuxerMaxBytes = config.demuxerMaxBytes,
demuxerMaxBackBytes = config.demuxerMaxBackBytes
)
if (config.autoplay) {
@@ -236,6 +251,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlaybackRate(0.0)
}
/**
* Stop playback and release decoder resources.
*
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
* on a background thread (flushing the demuxer and releasing the
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
*
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
* nativeDestroy has an internal use-after-free on the JNI global ref
* path, so the native mpv handle is intentionally left for the JVM GC
* / native finalizer rather than torn down synchronously. See
* [MPVLib] class doc for the full rationale.
*
* Call this BEFORE navigating away from the player screen so the
* decoder is reclaimed before the next screen (or the next episode's
* player) mounts. Otherwise Expo Router renders the new screen first
* and you briefly have two mpv instances + two 4K decoders alive —
* instant OOM on a 2 GB device.
*/
fun destroy() {
renderer?.stop()
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// TextureView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
// against mpv == null, where every mpv?.command() (including the
// "stop" and load commands) silently no-ops, leaving a black frame.
//
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
// which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount.
rendererStarted = false
currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
@@ -479,13 +539,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Cleanup
/**
* Proactively tear down the player. Called from onDetachedFromWindow so
* the app releases mpv + decoder buffers when the View detaches from the
* window. The JS-facing destroy() is intentionally thinner (just
* renderer.stop()) — see this thread for why the full teardown was kept
* off the JS path.
*/
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
surfaceTexture = null
renderer?.delegate = null
// Release the Surface that wraps the SurfaceTexture. These Surface
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
activeSurface = null
surfaceReady = false
currentUrl = null
rendererStarted = false
}
override fun onDetachedFromWindow() {

View File

@@ -1020,12 +1020,44 @@ final class MPVLayerRenderer {
info["cacheSeconds"] = cacheSeconds
}
// Configured cache limits read back from mpv to confirm user
// settings actually took effect. mpv stores byte sizes as int64
// (bytes); convert to MiB for display.
var demuxerMaxBytes: Int64 = 0
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
}
var demuxerMaxBackBytes: Int64 = 0
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
}
var cacheSecsLimit: Double = 0
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
info["cacheSecsLimit"] = cacheSecsLimit
}
// Dropped frames
var droppedFrames: Int64 = 0
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
info["droppedFrames"] = Int(droppedFrames)
}
// Active video output driver
if let voDriver = getStringProperty(handle: handle, name: "vo") {
info["voDriver"] = voDriver
}
// Active hardware decoder
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
info["hwdec"] = hwdec
}
// Estimated video output fps (post-filter)
var estimatedVfFps: Double = 0
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
info["estimatedVfFps"] = estimatedVfFps
}
return info
}
}

View File

@@ -74,7 +74,13 @@ public class MpvPlayerModule: Module {
AsyncFunction("pause") { (view: MpvPlayerView) in
view.pause()
}
// Synchronously destroy mpv instance + decoder before navigating
// away from the player screen (cross-platform; matches Android).
AsyncFunction("destroy") { (view: MpvPlayerView) in
view.destroy()
}
// Async function to seek to position
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
view.seekTo(position: position)

View File

@@ -289,6 +289,49 @@ class MpvPlayerView: ExpoView {
pipController?.updatePlaybackState()
}
/**
* Synchronously stop and destroy the mpv instance + decoder so memory is
* freed before the next screen mounts. Safe to call multiple times the
* underlying renderer.stop() guards against re-entry.
*
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
*/
func destroy() {
renderer?.stop()
// Reset view state and re-create the mpv handle so a subsequent
// loadVideo() on the SAME view instance can actually load.
// Without this, stop() leaves renderer.mpv == nil, and the next
// loadVideo(config:) calls renderer.load() which early-returns
// at `guard let handle = self.mpv else { return }` but only
// after flipping isLoading = true and dispatching the loading
// delegate callback, so the JS layer is stuck in a perpetual
// "loading" state with no actual playback.
//
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
// which call destroy() immediately before router.replace() to
// the same route Expo Router reuses the same MpvPlayerView
// instance, so the next `source` prop update arrives on this
// view without a remount. setupView() is otherwise the only
// place start() is called, so without re-starting here the
// renderer stays dead until the whole view is unmounted and
// recreated.
//
// start() is idempotent (`guard !isRunning else { return }`)
// and stop() has already nulled mpv synchronously before
// dispatching the async mpv_terminate_destroy, so creating a
// fresh handle here is safe even while the old handle's
// teardown is still in flight on a background queue (libmpv
// handles are independent).
currentURL = nil
intendedPlayState = false
do {
try renderer?.start()
} catch {
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
}
}
func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position

View File

@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
export interface MpvPlayerViewRef {
play: () => Promise<void>;
pause: () => Promise<void>;
/**
* Synchronously destroy the mpv instance + decoder + surface buffers.
* Call before navigating away from the player screen so memory is
* freed before the next screen mounts. Safe to call multiple times.
*/
destroy: () => Promise<void>;
// Pre-libmpv-1.0 alias (kept for source-history reference):
// stop: () => Promise<void>;
seekTo: (position: number) => Promise<void>;
seekBy: (offset: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
@@ -154,9 +162,17 @@ export type TechnicalInfo = {
videoBitrate?: number;
audioBitrate?: number;
cacheSeconds?: number;
/** Configured demuxer forward cache cap (MiB), read back from mpv */
demuxerMaxBytes?: number;
/** Configured demuxer backward cache cap (MiB), read back from mpv */
demuxerMaxBackBytes?: number;
/** Configured cache-secs floor, read back from mpv */
cacheSecsLimit?: number;
droppedFrames?: number;
/** Active video output driver (read from MPV at runtime) */
voDriver?: string;
/** Active hardware decoder (read from MPV at runtime) */
hwdec?: string;
/** Estimated video output fps (mpv "estimated-vf-fps") */
estimatedVfFps?: number;
};

View File

@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
pause: async () => {
await nativeRef.current?.pause();
},
destroy: async () => {
await nativeRef.current?.destroy();
},
seekTo: async (position: number) => {
await nativeRef.current?.seekTo(position);
},

View File

@@ -134,7 +134,7 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "6.0.3"
},

View File

@@ -27,6 +27,9 @@ module.exports = function withCustomPlugin(config) {
// https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
// NDK version required by libmpv 1.0.0
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
// Increase memory
config = setGradlePropertiesValue(
config,

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"seeAll": "See all",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"not_connected": "Not connected"
},
"search": {
"search": "Search...",
@@ -732,6 +736,10 @@
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"cast": "Cast",
"details": "Details",
"status": "Status",

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"seeAll": "See all",
"connect": "Anslut",
"connecting": "Ansluter…",
"connected": "Ansluten",
"not_connected": "Inte ansluten"
},
"search": {
"search": "Sök...",
@@ -732,6 +736,10 @@
"request_button": "Önska",
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
"failed_to_login": "Inloggningen Misslyckades",
"connect_to_jellyseerr": "Anslut till Jellyseerr",
"connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
"session_expired": "Sessionen har gått ut",
"session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
"cast": "Roller",
"details": "Detaljer",
"status": "Status",

View File

@@ -9,6 +9,7 @@ import {
import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -361,11 +362,16 @@ export const defaultValues: Settings = {
mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75,
// MPV buffer/cache defaults
// MPV buffer/cache defaults.
// Android TV gets tighter caps — combined with libmpv 1.0's larger
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
// retention) the larger mobile budget pushes 2 GB Android TV boxes
// into swap death during 4K HDR playback. Apple TV has more RAM and
// keeps the full budget. Users can override via the settings screen.
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
// MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next",
// Gesture controls