Compare commits

...

55 Commits

Author SHA1 Message Date
lance chant
90e9084949 Merge branch 'develop' into fix/subtitle-track-selection 2026-06-30 10:03:52 +02:00
lance chant
aa0eb0a655 fix: changing pip to use surface view (#1778)
Some checks are pending
🚦 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
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🏗️ 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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-30 09:58:25 +02:00
Gauvain
115c163aeb Merge branch 'develop' into fix/subtitle-track-selection 2026-06-30 09:04:31 +02:00
Gauvain
286a3cad47 refactor: migrate app.config and Expo config plugins to TypeScript (#1718) 2026-06-30 09:03:47 +02:00
Gauvain
a58a4da4f3 fix(subtitles): guard track-list effect against stale async runs
The track-building effect in VideoContext reruns once api?.basePath and
isCurrentSubImageBased settle. An earlier async run could resolve after a
rerun and overwrite subtitleTracks/audioTracks with setTrack callbacks bound
to a stale `api`, breaking external-subtitle identity matching.

Add a cancellation token and route every state commit through guarded
committers so all six commit points (offline-transcoded audio/subs,
burned-in, and the online audio/subs paths) drop writes from a dead run,
plus bail out right after the awaited getAudioTracks when cancelled.
2026-06-30 01:56:58 +02:00
Gauvain
c02baf2831 fix(subtitles): rebuild track callbacks when isCurrentSubImageBased changes
The setTrack/Disable callbacks close over isCurrentSubImageBased for the
transcode replacePlayer decision; add it to the track-building effect deps so
they rebuild when it flips (otherwise, in a transcoding session, callbacks could
stay on the MPV path after switching to/from a burned-in image sub and the
player would not refresh). Addresses CodeRabbit.
2026-06-30 01:41:21 +02:00
Gauvain
3848877021 fix(subtitles): only show the burned-in entry for image subs in offline transcoded
When an image subtitle was burned into a transcoded download it lives in the
video pixels and can't be disabled or swapped. Show only that '(burned in)'
entry instead of advertising Disable/text controls whose handlers can't affect
it (which would let the UI show a different selection than what's on screen).
Addresses CodeRabbit.
2026-06-30 01:32:43 +02:00
Gauvain
1f54ccc52c fix(subtitles): rebuild VideoContext track callbacks when api.basePath changes
The setTrack callbacks build external-sub URLs from api?.basePath; add it to the
track-building effect deps so the list rebuilds once the API is ready (otherwise
online externals could resolve with undefined → notFound). Addresses CodeRabbit.
2026-06-30 01:19:18 +02:00
Gauvain
08efa1b0f7 Merge branch 'develop' into fix/subtitle-track-selection 2026-06-30 01:10:59 +02:00
Gauvain
90ea934548 fix(subtitles): address CodeRabbit review
- Unify external detection: isExternalSubtitle drops the bare-DeliveryUrl case
  (an Hls-delivered sub has a DeliveryUrl but isn't sub-add-ed) so sorting,
  loading and resolution agree; compareTracksForMenu now uses it.
- applyMpvSubtitleSelection wraps player calls in try/catch — fire-and-forget
  call sites no longer risk unhandled rejections.
- VideoContext offline-transcoded branch: treat missing IsTextSubtitleStream as
  text (use !isImageBasedSubtitle), matching the shared helper.
- ItemContent.tv refreshSubtitleTracks: apply compareTracksForMenu like the
  initial list.
- Tests: use the @/ alias; rework the embedded cases to actually exercise
  identity (reversed player order) and the ordinal fallback (same-language,
  no title).
2026-06-30 01:08:50 +02:00
Gauvain
1c158dea4e fix(subtitles): order detail-page & TV subtitle menus like jellyfin-web
The detail-page selector (MediaSourceButton, the #1176 replacement for
TrackSheet) and the TV detail/refresh paths (ItemContent.tv, Controls.tv
refreshSubtitleTracks) still listed subtitles in raw MediaStreams order
(externals first). Apply compareTracksForMenu there too so every menu
matches web. The in-player TV modal was already covered (fed from the
sorted VideoContext tracks).
2026-06-30 00:39:29 +02:00
Gauvain
9a7b9c9de2 fix(subtitles): select subtitles by identity across all player paths
direct-player resolves the selection on onTracksReady (online + offline, init +
runtime), VideoContext does the same for the mobile menu (incl. offline-transcoded),
and the menus (SubtitleTrackSelector, VideoContext, TVSubtitleSheet) now order
tracks like jellyfin-web. Fixes wrong-subtitle/wrong-language selection.

Fixes #954
2026-06-30 00:11:45 +02:00
Gauvain
ceeacda7f9 feat(subtitles): identity-based track resolver + jellyfin-web menu order
resolveSubtitleTrack matches a Jellyfin subtitle against the player's real
track list (external by external-filename, embedded by language/title) instead
of positional counting, which mis-selects when externals/embedded are reordered
or the server hides embedded subs. applyMpvSubtitleSelection is the shared entry
point (reusable for the cast backend). compareTracksForMenu mirrors web's
itemHelper.sortTracks. Drops dead getMpvSubtitleId/isSubtitleInMpv. 14 unit tests.
2026-06-30 00:11:12 +02:00
Gauvain
b8780f34ec feat(mpv): expose external/external-filename/ff-index/codec on subtitle track-list
These identity fields let the JS layer map a Jellyfin subtitle to the real
MPV track instead of guessing a positional sid.
2026-06-30 00:10:57 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
Some checks failed
🚦 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
🏗️ 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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (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 (typecheck) (push) Has been cancelled
2026-06-29 19:48:32 +02:00
lance chant
cc0007926d fix: fixing some performance issues and mpv upgrade (#1765)
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
🛡️ Trivy Security Scan / 🔎 Filesystem scan (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 (typecheck) (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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
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
renovate[bot]
ce66f0256e chore(deps): Update dependency @shopify/flash-list to v2.0.3 (#1629) 2026-06-18 21:53:02 +02:00
renovate[bot]
2ec6594462 chore(deps): Update CI dependencies to v24.17.0 (#1745) 2026-06-18 21:19:10 +02:00
renovate[bot]
18f01fa4ab chore(deps): Update actions/checkout action to v7 (#1746)
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 - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 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 / 📝 Validate PR Title (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
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-18 20:46:54 +02:00
Thomas Frøysa
df56d62acd fix(android): hold wake and wifi locks during downloads (#1546)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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
🏷️🔀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 (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: Simon Eklundh <github@oakgrove.site>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 13:26:40 +02:00
lance chant
872d14786e fix: apple top shelf currently cropping images (#1726)
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 / 🚑 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
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 11:59:21 +02:00
github-actions[bot]
5bf07b4798 feat: New Crowdin Translations (#1699)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 11:32:38 +02:00
renovate[bot]
c3dceedad0 chore(deps): Update dependency lint-staged to v17.0.7 (#1607)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 10:56:35 +02:00
Gauvain
bf3dc4a366 ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix (#1734) 2026-06-17 09:39:43 +02:00
renovate[bot]
3e81291843 chore(deps): Update CI dependencies to v5.3.0 (#1737)
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (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-16 23:28:08 +02:00
renovate[bot]
7703a1c76f chore(deps): Update CI dependencies to v2.16.3 (#1735) 2026-06-16 21:51:59 +02:00
renovate[bot]
7983c68b9f chore(deps): Lock file maintenance (#1706) 2026-06-16 21:00:59 +02:00
Gauvain
335f1efb2c chore(deps): upgrade TypeScript to 6.0.3 and align Expo SDK 56 packages (#1719) 2026-06-16 20:28:02 +02:00
Gauvain
434cb3bd39 ci: ARM Android runners, slimmer APK artifacts, Renovate-pinned tool versions (#1733)
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
🔒 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
🏗️ 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
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (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-16 17:12:32 +02:00
Gauvain
7a6daa011d ci(build): drop free-disk-space apt-get warning + surface unsigned tvOS in artifact comment (#1732)
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
2026-06-15 20:32:53 +02:00
Gauvain
149e3b1b17 fix(hooks): correct useMemo dependency arrays (#1626)
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
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-15 01:01:38 +02:00
Gauvain
f00dad02ba ci(issue-form): auto-populate version dropdown from GitHub releases (#1641) 2026-06-15 00:24:44 +02:00
Gauvain
b7ec841118 refactor: remove dead code (#1625)
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-14 18:30:50 +02:00
Gauvain
03864b2a9a feat(home): refresh Continue Watching instantly via WebSocket (#1439) 2026-06-14 17:20:27 +02:00
Gauvain
96116e0451 feat(settings): show Actions run number for CI builds, hide store build number (#1711)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ 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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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
2026-06-11 11:08:07 +02:00
lance chant
938918fa06 fix: android tv issues (#1672)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-11 10:24:11 +02:00
renovate[bot]
a4b6f456f2 chore(deps): Update CI dependencies to v3.1.0 (#1715)
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 / 🚑 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 / 🔍 Vulnerable Dependencies (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-11 09:10:19 +02:00
Gauvain
0a2dadffd2 feat(settings): graduated version tracking (build, branch, commit) (#1677)
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 / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (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
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-06-10 17:19:28 +02:00
Gauvain
6818ea380f fix(renovate): resolve maven lookups, unnest config, gate Expo SDK updates (#1708)
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 (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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
2026-06-10 11:22:49 +02:00
lance chant
7cf0a13317 fix: an issue with save account didn't show the modal (#1705)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-10 10:28:18 +02:00
renovate[bot]
168bf2e54e chore(deps): Update github/codeql-action action to v4.36.2 (#1687)
Some checks failed
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (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 / 🚑 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 / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (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
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-09 23:25:26 +02:00
renovate[bot]
6f0230c2ca chore(deps): Update expo/expo-github-action digest to eab7a23 (#1685) 2026-06-09 22:37:14 +02:00
renovate[bot]
d12beee529 chore(deps): Update actions/checkout action to v6.0.3 (#1686) 2026-06-09 21:43:22 +02:00
renovate[bot]
02ffac167b chore(deps): Update dependency xcode to v26.5 (#1609)
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (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-09 15:58:35 +02:00
renovate[bot]
4eb734c99f chore(deps): Update expo monorepo to v1.19.9 (#1606) 2026-06-09 15:05:23 +02:00
Gauvain
b7bae0072f ci(i18n): detect unused & missing translation keys + clean key debt (#1636) 2026-06-09 11:46:42 +02:00
boolemancer
1685571406 fix(downloads): Use mediaSource.Id instead of item.Id in direct download URL (#1666)
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-08 14:59:29 +02:00
Gauvain
36ed7539a2 ci(security): add Trivy filesystem scan to code scanning (#1644) 2026-06-08 14:05:23 +02:00
163 changed files with 4617 additions and 5762 deletions

View File

@@ -75,10 +75,13 @@ body:
id: version
attributes:
label: Streamyfin Version
description: What version of Streamyfin are you using?
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options:
- 0.54.1
- 0.51.0
- 0.47.1
- 0.30.2
- 0.28.0
- Older
- TestFlight/Development build
validations:

View File

@@ -0,0 +1,21 @@
name: Refresh PR build comment
description: >-
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
comment reflects live per-platform progress as each build job finishes.
runs:
using: composite
steps:
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
# artifact-comment's concurrency group collapses simultaneous nudges, so
# this can't spam the comment. Skipped on forks (their read-only token
# cannot dispatch). github.token is used because composite actions cannot
# read the secrets context.
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.head_ref }}
REPO: ${{ github.repository }}
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

64
.github/renovate.json vendored
View File

@@ -30,9 +30,17 @@
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
},
{
"customType": "regex",
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
"datasourceTemplate": "npm",
"depNameTemplate": "bun"
}
],
"customDatasources": {
@@ -44,22 +52,42 @@
]
}
},
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
"matchPackageNames": [
"expo",
"react",
"react-dom",
"react-native",
"react-native-web",
"expo-*",
"@expo/*"
],
"groupName": "Expo SDK",
"dependencyDashboardApproval": true
},
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
"matchDatasources": ["maven"],
"registryUrls": [
"https://dl.google.com/dl/android/maven2/",
"https://repo.maven.apache.org/maven2/"
]
}
]
}

View File

@@ -18,7 +18,7 @@ jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,21 +184,35 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// Map our build targets to their job display names. Exact name is
// tried first so a signed target never collides with its
// "(Unsigned)" sibling (whose name contains the signed name).
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)'],
'Android TV': ['🤖 Build Android APK (TV)'],
'iOS': ['🍎 Build iOS IPA (Phone)'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
'tvOS': ['🍎 Build tvOS IPA'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
};
// Prefer an exact name match over a substring match so
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
const findJobForTarget = (jobs, jobNames) =>
jobs.find(j => jobNames.some(name => j.name === name)) ||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
// Format a millisecond duration as "Xm Ys".
const fmtDuration = (ms) => {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return `${min}m ${sec}s`;
};
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -229,10 +243,8 @@ jobs:
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
const job = findJobForTarget(jobs.jobs, jobNames);
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -358,6 +370,43 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Pull per-job durations from the latest successful develop build so
// in-progress / queued targets can show a realistic ETA instead of
// an open-ended spinner. Best-effort: any failure just drops the ETA.
let referenceDurations = {};
try {
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build-apps.yml',
branch: 'develop',
status: 'success',
per_page: 1
});
if (devRuns.workflow_runs.length > 0) {
const refRun = devRuns.workflow_runs[0];
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: refRun.id
});
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = findJobForTarget(refJobs.jobs, jobNames);
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
}
}
console.log(`Reference durations from develop run ${refRun.id}:`,
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
} else {
console.log('No successful develop build found for ETA reference');
}
} catch (error) {
console.log('Failed to fetch develop reference durations:', error.message);
}
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -369,9 +418,9 @@ jobs:
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
@@ -387,11 +436,12 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// tvOS builds are temporarily disabled until feat/tv-interface
// is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
// Signed tvOS stays disabled until EAS has tvOS provisioning
// profiles (app + TopShelf targets); non-interactive builds can't
// create them. Unsigned tvOS builds, so it flows through normally.
if (target.name === 'tvOS') {
status = '💤 Disabled';
downloadLink = '*Disabled until feat/tv-interface is merged*';
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
@@ -406,11 +456,9 @@ jobs:
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
durationInfo = ` - ${fmtDuration(durationMs)}`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
@@ -420,10 +468,16 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
downloadLink = '*Build in progress...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
: '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
downloadLink = '*Waiting to start...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
: '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
@@ -444,7 +498,22 @@ jobs:
commentBody += `\n`;
// Show installation instructions if we have any artifacts
// Static rundown of the build optimisations + what each artifact
// installs on. Always shown (even mid-build) so testers know what
// to expect before downloads are ready.
commentBody += `<details>\n`;
commentBody += `<summary>📦 Build details &amp; device compatibility</summary>\n\n`;
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
commentBody += `| Artifact | Architectures | Installs on |\n`;
commentBody += `|---|---|---|\n`;
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
commentBody += `</details>\n\n`;
// Installation instructions only matter once something is downloadable.
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;

View File

@@ -11,13 +11,23 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
@@ -28,12 +38,12 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -43,45 +53,58 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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-develop-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
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
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
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 }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
# sideload target). Overrides app.json buildArchs for this build only,
# so local `bun run android` (x86_64 emulator) is unaffected.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -95,12 +118,16 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
@@ -111,12 +138,12 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -126,45 +153,57 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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-develop-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
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
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
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 }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -178,16 +217,20 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -197,15 +240,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -219,10 +263,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -231,7 +275,9 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: eas build -p ios --local --non-interactive
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -243,16 +289,20 @@ jobs:
path: build-*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -262,15 +312,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -284,7 +335,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
@@ -301,18 +352,24 @@ jobs:
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-tv:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build tvOS IPA
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -322,15 +379,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -344,10 +402,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -356,7 +414,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive
run: eas build -p ios --local --non-interactive --profile ci_tv
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -377,10 +435,11 @@ jobs:
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -390,15 +449,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -412,7 +472,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
@@ -428,3 +488,6 @@ jobs:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment

View File

@@ -13,13 +13,13 @@ concurrency:
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -29,14 +29,17 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 🛡️ Verify lockfile consistency
run: |

View File

@@ -8,11 +8,14 @@ on:
schedule:
- cron: '24 2 * * *'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write
@@ -24,16 +27,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -10,14 +10,14 @@ on:
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -19,16 +19,16 @@ permissions:
jobs:
sync-translations:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_translations: true

View File

@@ -15,21 +15,22 @@ jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
run: bun scripts/detect-duplicate-issue.ts
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -15,7 +15,7 @@ jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
pull-requests: write
contents: read
@@ -46,12 +46,12 @@ jobs:
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -65,11 +65,10 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -78,17 +77,21 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
# without failing the gate (some checks are known-noisy for this setup).
continue-on-error: true
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
@@ -97,10 +100,11 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -109,12 +113,14 @@ jobs:
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
# renovate: datasource=node-version depName=node versioning=node
node-version: "24.18.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -12,7 +12,7 @@ on:
jobs:
notify:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
@@ -29,7 +29,7 @@ jobs:
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure

View File

@@ -22,8 +22,9 @@ on:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
environment: production
permissions: {}
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
@@ -31,7 +32,7 @@ jobs:
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
strategy:
@@ -63,7 +64,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
@@ -72,15 +73,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
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 }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -88,7 +90,7 @@ jobs:
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -176,13 +178,13 @@ jobs:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
show-progress: false

50
.github/workflows/trivy-scan.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Trivy's own action caches the vulnerability DB + binary internally
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
# step is needed — it only duplicated the cache.
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -1,67 +1,103 @@
name: 🐛 Update Bug Report Template
name: 🐛 Update Issue Form Versions
on:
release:
types: [published] # Run on every published release on any branch
# Only full releases populate the dropdown (no drafts/prereleases).
types: [released]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
# Fixed group so a release event and the weekly cron can't race on the same
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
group: update-issue-form
cancel-in-progress: false
permissions:
contents: read
jobs:
update-bug-report:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-26.04
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
node-version: '24.x'
cache: 'npm'
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
result-encoding: string
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: 🔢 Populate version dropdown from GitHub releases
id: populate
run: bun scripts/update-issue-form.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
- name: 📬 Create pull request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci/update-issue-form
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version'
commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
# 📦 Pull Request
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [x] Verified that changes behave as expected for all platforms
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [x] No secrets, hardcoded credentials, or private config files are included
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
- name: 🔀 Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
# (GitHub anti-recursion), so the required checks stay "Expected" until a
# maintainer kicks them (close/reopen the PR, or push an empty commit).
# Auto-merge is still worth enabling: once checks run and reviews land,
# the PR merges itself.
run: |
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js)
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -143,14 +143,6 @@ interface ModalOptions {
}
```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling
The modal uses these default styles (can be overridden via options):

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,29 +0,0 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
};

79
app.config.ts Normal file
View File

@@ -0,0 +1,79 @@
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args: string[]): string | null => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
};
const buildMeta = {
commit:
(
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
process.env.GITHUB_SHA ||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
git(["rev-parse", "HEAD"]) ||
""
).slice(0, 7) || null,
branch:
process.env.EAS_BUILD_GIT_BRANCH ||
process.env.GITHUB_HEAD_REF ||
process.env.GITHUB_REF_NAME ||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
null,
profile:
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") {
config.plugins?.push("expo-background-task");
config.plugins?.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins?.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
[
"expo-build-properties",
{
@@ -107,6 +107,9 @@
],
"expo-localization",
"expo-asset",
"expo-audio",
"expo-image",
"expo-sharing",
[
"react-native-edge-to-edge",
{
@@ -131,17 +134,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
[
"./plugins/withGitPod.js",
"./plugins/withGitPod.ts",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -305,6 +305,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

@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
applyMpvSubtitleSelection,
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { msToTicks, ticksToSeconds } from "@/utils/time";
@@ -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(() => {
@@ -626,12 +639,9 @@ export default function DirectPlayerPage() {
).map((s) => s.DeliveryUrl!);
}
// Calculate track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
// Audio maps positionally (audio tracks aren't reordered or hidden like
// subtitles). The subtitle selection is applied later, once MPV's real track
// list is known — see applySubtitleSelection / onTracksReady.
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
@@ -649,7 +659,6 @@ export default function DirectPlayerPage() {
url: stream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
@@ -697,7 +706,6 @@ export default function DirectPlayerPage() {
playbackPositionFromUrl,
api?.basePath,
api?.accessToken,
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
@@ -895,30 +903,41 @@ export default function DirectPlayerPage() {
);
// TV subtitle track change handler
/**
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
* it. Identity-based (external by filename, embedded by language/title) so it
* stays correct across external/embedded reordering and server-hidden embedded
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
* fired again after each external sub-add) and runtime changes.
*/
const applySubtitleSelection = useCallback(
async (jellyfinSubtitleIndex: number) => {
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
await applyMpvSubtitleSelection(videoRef.current, {
subtitleStreams,
jellyfinSubtitleIndex,
// The exact URL each external sub was loaded into MPV with — mirrors the
// externalSubtitles array built in videoSource (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl).
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
},
});
},
[stream?.mediaSource, offline, api?.basePath],
);
// TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
await applySubtitleSelection(index);
},
[stream?.mediaSource],
[applySubtitleSelection],
);
// Technical info toggle handler
@@ -1105,6 +1124,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 +1143,7 @@ export default function DirectPlayerPage() {
bitrateValue,
router,
isPlaybackStopped,
videoRef,
]);
// Apply subtitle settings when video loads
@@ -1273,6 +1302,10 @@ export default function DirectPlayerPage() {
}}
onTracksReady={() => {
setTracksReady(true);
// Fired after embedded tracks enumerate and again after each
// external sub-add; re-resolve so the final fire (full track
// list) selects the right track by identity.
void applySubtitleSelection(currentSubtitleIndex);
}}
/>
{!hasPlaybackStarted && (

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

@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

458
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -56,6 +56,7 @@ import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -232,12 +233,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks (raw MediaStream[] for label lookup)
// Get available subtitle tracks (raw MediaStream[] for label lookup),
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
const subtitleStreams = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ?? [];
return streams ? [...streams].sort(compareTracksForMenu) : [];
}, [selectedOptions?.mediaSource]);
// Store handleSubtitleChange in a ref for stable callback reference
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)
: freshItem.MediaSources?.[0];
// Get subtitle streams from the fresh data
const streams =
mediaSource?.MediaStreams?.filter(
// Get subtitle streams from the fresh data, ordered like jellyfin-web
// (embedded first, externals last) — same as the initial list.
const streams = [
...(mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? [];
) ?? []),
].sort(compareTracksForMenu);
// Convert to Track[] with setTrack callbacks
const tracks: Track[] = streams.map((stream) => ({

View File

@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
// Order like jellyfin-web (embedded first, externals last, forced/default up).
[
...(selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || []),
].sort(compareTracksForMenu),
[selectedOptions.mediaSource],
);

View File

@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid],
);
const isPresentedRef = useRef(false);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else {
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
resetState();
onClose();
}

View File

@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { tc } from "@/utils/textTools";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
// Order like jellyfin-web (embedded first, externals last, forced/default up).
return subs ? [...subs].sort(compareTracksForMenu) : subs;
}, [source]);
const selectedSubtitleSteam = useMemo(

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source],
[source, streamType],
);
const selectedSteam = useMemo(

View File

@@ -1,20 +0,0 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -1,28 +0,0 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

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

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[library],
[api, library],
);
const itemType = useMemo(() => {

View File

@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
<View style={styles.buttonContainer}>
<TVSubmitButton
onPress={handleSubmit}
label={t("login.login")}
label={t("login.login_button")}
loading={isLoading}
disabled={!password}
/>

View File

@@ -1,12 +0,0 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

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

@@ -1,63 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -1,48 +0,0 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
TextInput fallback on Android TV (the native module is Apple-only). */}
{Platform.OS === "ios" ? (
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
</View>
<ScrollView

View File

@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
return (
<View {...props}>

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

@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
@@ -317,8 +318,10 @@ export const Controls: FC<Props> = ({
try {
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
// Skip streams without a real index: `?? -1` would alias them to the
// "disable subtitles" sentinel and mis-route selection.
return streams
// "disable subtitles" sentinel and mis-route selection. Order like
// jellyfin-web (embedded first, externals last, forced/default up).
return [...streams]
.sort(compareTracksForMenu)
.filter((stream) => typeof stream.Index === "number")
.map((stream) => {
const index = stream.Index as number;

View File

@@ -33,6 +33,7 @@ import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
@@ -96,13 +97,19 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Order like jellyfin-web (embedded first, externals last, forced/default up).
const sortedTracks = useMemo(
() => [...subtitleTracks].sort(compareTracksForMenu),
[subtitleTracks],
);
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
const trackIdx = sortedTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
}, [sortedTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
@@ -215,7 +222,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = subtitleTracks.map((track) => ({
const options = sortedTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
@@ -223,7 +230,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
}, [sortedTracks, currentSubtitleIndex, t]);
if (!visible) return null;

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

@@ -23,32 +23,29 @@
* - Used to report playback state to Jellyfin server
* - Value of -1 means disabled/none
*
* 2. MPV INDEX (track.mpvIndex)
* - MPV's internal track ID
* - MPV orders tracks as: [all embedded, then all external]
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
* 2. PLAYER TRACK (selected by IDENTITY, not position)
* - Selection resolves the server Index against MPV's REAL track list via
* applyMpvSubtitleSelection: externals matched by external-filename,
* embedded by language/title. `track.mpvIndex` is no longer used to select
* (kept -1) — positional mapping mis-selected when externals/embedded were
* reordered or the server hid embedded subs (#954 et al.).
*
* ============================================================================
* SUBTITLE HANDLING
* ============================================================================
*
* Embedded (DeliveryMethod.Embed):
* - Already in MPV's track list
* - Select via setSubtitleTrack(mpvId)
*
* External (DeliveryMethod.External):
* - Loaded into MPV on video start
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
* Embedded & External:
* - Selected via applyMpvSubtitleSelection (identity match against the live
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
* embedded first, externals last, forced/default float up).
*
* Image-based during transcoding:
* - Burned into video by Jellyfin, not in MPV
* - Requires replacePlayer() to change
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
*/
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { File } from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import type React from "react";
import {
createContext,
@@ -61,9 +58,14 @@ import {
import { Platform } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import {
applyMpvSubtitleSelection,
compareTracksForMenu,
isImageBasedSubtitle,
} from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -87,6 +89,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
const playerControls = usePlayerControls();
const offline = useOfflineMode();
const api = useAtomValue(apiAtom);
const router = useRouter();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
@@ -141,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!tracksReady) return;
// Guard every state commit against stale runs: api?.basePath /
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
// earlier async run (which captured an old `api`) must not finish later and
// overwrite the fresh track list with callbacks bound to stale closures.
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
let cancelled = false;
const commitSubtitleTracks = (next: Track[]) => {
if (!cancelled) setSubtitleTracks(next);
};
const commitAudioTracks = (next: Track[]) => {
if (!cancelled) setAudioTracks(next);
};
const fetchTracks = async () => {
// Check if this is offline transcoded content
// For transcoded offline content, only ONE audio track exists in the file
@@ -166,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
},
];
setAudioTracks(audio);
commitAudioTracks(audio);
} else {
// Fallback: show no audio tracks if the stored track wasn't found
setAudioTracks([]);
commitAudioTracks([]);
}
// For subtitles in transcoded offline content:
@@ -179,6 +195,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
downloadedItem.userData.subtitleStreamIndex;
const subs: Track[] = [];
// If an IMAGE subtitle was burned into the transcoded download it's in the
// video pixels — it can't be turned off or swapped. Show only that entry
// instead of advertising "Disable"/text controls that can't affect it.
const burnedInSub = allSubs.find(
(s) => s.Index === downloadedSubtitleIndex,
);
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
commitSubtitleTracks([
{
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
index: burnedInSub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {},
},
]);
return;
}
// Add "Disable" option
subs.push({
name: "Disable",
@@ -190,123 +224,82 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
});
// For text-based subs, they should still be available in the file
let subIdx = 1;
for (const sub of allSubs) {
if (sub.IsTextSubtitleStream) {
// Text subs are muxed into the transcoded file and switchable; resolve by
// identity against MPV's real track list (same as online). Order matches web.
// Image subs aren't in the transcoded file (only the burned one was, handled
// above), so skip them here.
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
if (!isImageBasedSubtitle(sub)) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: subIdx,
mpvIndex: -1,
setTrack: () => {
playerControls.setSubtitleTrack(subIdx);
router.setParams({ subtitleIndex: String(sub.Index) });
},
});
subIdx++;
} else if (sub.Index === downloadedSubtitleIndex) {
// This image-based sub was burned in - show it but indicate it's active
subs.push({
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
index: sub.Index ?? -1,
mpvIndex: -1, // Can't be changed
setTrack: () => {
// Already burned in, just update params
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
},
});
}
}
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
commitSubtitleTracks(subs);
return;
}
// MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null);
if (cancelled) return;
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
// Separate embedded vs external subtitles from Jellyfin's list
// MPV orders tracks as: [all embedded, then all external]
const embeddedSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
);
const externalSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
);
// Count embedded subs that will be in MPV
// (excludes image-based subs during transcoding as they're burned in)
const embeddedInPlayer = embeddedSubs.filter(
(s) => !isTranscoding || !isImageBasedSubtitle(s),
);
const subs: Track[] = [];
// Process all Jellyfin subtitles
for (const sub of allSubs) {
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
const isExternal =
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
// For image-based subs during transcoding, need to refresh player
if (isTranscoding && isImageBasedSubtitle(sub)) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {
replacePlayer({ subtitleIndex: String(sub.Index) });
},
});
continue;
}
// Calculate MPV track ID based on type
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
let mpvId = -1;
if (isEmbedded) {
// Find position among embedded subs that are in player
const embeddedPosition = embeddedInPlayer.findIndex(
(s) => s.Index === sub.Index,
);
if (embeddedPosition !== -1) {
mpvId = embeddedPosition + 1; // 1-based ID
}
} else if (isExternal) {
// Find position among external subs, offset by embedded count
const externalPosition = externalSubs.findIndex(
(s) => s.Index === sub.Index,
);
if (externalPosition !== -1) {
mpvId = embeddedInPlayer.length + externalPosition + 1;
}
}
// Process all Jellyfin subtitles. Selection resolves against MPV's real
// track list by identity (applyMpvSubtitleSelection) — never positional
// index math, which mis-selects across external/embedded reordering and
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
// Image-based subs during transcoding are burned into the video by the
// server; both switching TO one and switching AWAY from a currently
// active one require a player refresh (re-transcode), not a track change.
const needsReplace =
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: mpvId,
mpvIndex: -1,
setTrack: () => {
// Transcoding + switching to/from image-based sub
if (
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
) {
if (needsReplace) {
replacePlayer({ subtitleIndex: String(sub.Index) });
return;
}
// Direct switch in player
if (mpvId !== -1) {
playerControls.setSubtitleTrack(mpvId);
router.setParams({ subtitleIndex: String(sub.Index) });
return;
}
// Fallback - refresh player
replacePlayer({ subtitleIndex: String(sub.Index) });
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
// Mirror how external subs are loaded into MPV (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
// external-filename lines up.
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
},
});
}
@@ -374,12 +367,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
}
}
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
// at the front (unshifted), local downloaded subs at the end.
commitSubtitleTracks(subs);
commitAudioTracks(audio);
};
fetchTracks();
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
return () => {
cancelled = true;
};
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
// API is ready so online externals don't resolve with undefined.
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
// decision — rebuild when it flips so we refresh the stream when we should.
}, [
tracksReady,
mediaSource,
offline,
downloadedItem,
itemId,
api?.basePath,
isCurrentSubImageBased,
]);
return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>

View File

@@ -1,39 +0,0 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export default {
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -52,7 +52,7 @@
}
},
"production": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -64,7 +64,7 @@
}
},
"production-apk": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -74,7 +74,7 @@
}
},
"production-apk-tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -87,7 +87,7 @@
}
},
"production_tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"env": {
@@ -97,6 +97,14 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -1,35 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

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

@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);

View File

View File

@@ -2,7 +2,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".DownloadService"

View File

@@ -9,6 +9,7 @@ import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -27,6 +28,7 @@ class DownloadService : Service() {
private var currentDownloadTitle = "Preparing download..."
private var currentProgress = 0
private var isForegroundStarted = false
private var wakeLock: PowerManager.WakeLock? = null
inner class DownloadServiceBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
@@ -36,6 +38,12 @@ class DownloadService : Service() {
super.onCreate()
Log.d(TAG, "DownloadService created")
createNotificationChannel()
val pm = getSystemService(PowerManager::class.java)
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
wakeLock?.acquire()
Log.d(TAG, "Wake lock acquired")
}
override fun onBind(intent: Intent?): IBinder {
@@ -93,6 +101,8 @@ class DownloadService : Service() {
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
Log.d(TAG, "Wake lock released")
Log.d(TAG, "DownloadService destroyed")
super.onDestroy()
}

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,85 +524,96 @@ 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 }
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
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 }
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
track["external"] = external
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
track["externalFilename"] = it
}
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
}
return tracks
}
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 +621,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 +650,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 +663,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 +672,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 +837,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

@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -26,15 +24,30 @@ 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,
)
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* Uses TextureView for reliable Picture-in-Picture support.
*
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerView"
@@ -48,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
@@ -59,30 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
private var activeSurface: Surface? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init {
setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
// SurfaceView for video rendering. Routes the surface directly to
// SurfaceFlinger (the OS compositor), giving mpv a standalone
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
surfaceTextureListener = this@MpvPlayerView
}
addView(textureView)
surfaceView.holder.addCallback(this@MpvPlayerView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView)
pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -98,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
// Post size syncs after the PiP layout settles. Two passes
// catch both the immediate surface re-attach and the
// post-animation layout pass. Replaces the old TextureView
// measure/layout polling hack (forcePiPBufferSize).
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
} else {
// Restore from PiP: surface resized back to fullscreen.
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
@@ -121,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/**
* Start the renderer with the given VO driver.
* Called lazily on first loadVideo so the voDriver setting is available.
* Called lazily on first loadVideo so user settings are available.
*/
private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return
@@ -130,9 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
pendingSurface?.let { surface ->
// If the surface is already alive (surfaceCreated fired before
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface)
pendingSurface = null
syncSurfaceSizeToView()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -140,18 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// MARK: - TextureView.SurfaceTextureListener
// MARK: - SurfaceHolder.Callback
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
override fun surfaceCreated(holder: SurfaceHolder) {
val surface = holder.surface
surfaceReady = true
if (rendererStarted) {
// The previous Surface reference is holder-owned; do NOT release
// it (SurfaceView manages its lifecycle). Just track the new one.
activeSurface = surface
renderer?.attachSurface(surface)
} else {
pendingSurface = surface
// Push the actual view dimensions immediately so mpv doesn't
// render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
}
// If we have a pending load, execute it now
@@ -162,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height)
}
}
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
// Do NOT issue mpv "stop" here. Playback continues against the
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
/**
* Read the actual SurfaceView width/height and push them to mpv.
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
}
// MARK: - Video Loading
@@ -207,7 +258,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 +291,50 @@ 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
// SurfaceView 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.
//
// SurfaceView note: the surface is owned by the holder and survives
// across destroy()/loadVideo() on the same view instance. The next
// ensureRendererStarted() reads it from surfaceView.holder.surface.
rendererStarted = false
currentUrl = null
activeSurface = null
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
@@ -267,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture
fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
}
@@ -479,13 +529,24 @@ 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
// SurfaceView owns the Surface via its holder — do NOT release it.
activeSurface = null
surfaceReady = false
currentUrl = null
rendererStarted = false
}
override fun onDetachedFromWindow() {

View File

@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false)
val activity = getActivity() ?: return
// Push minimal params with just auto-enter disabled. Do NOT call
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
}
}
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
}
fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) {
playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) {
registerLifecycleCallbacks()
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
}
return builder.build()

View File

@@ -770,12 +770,32 @@ final class MPVLayerRenderer {
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
track["codec"] = codec
}
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
var external: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
track["external"] = external != 0
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
track["externalFilename"] = extFilename
}
var ffIndex: Int64 = 0
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
track["ffIndex"] = Int(ffIndex)
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
tracks.append(track)
}
@@ -1020,12 +1040,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>;
@@ -133,6 +141,14 @@ export type SubtitleTrack = {
id: number;
title?: string;
lang?: string;
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
codec?: string;
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
ffIndex?: number;
selected?: boolean;
};
@@ -154,9 +170,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

@@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter>
</receiver>
</application>

View File

@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean {
val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver
if (programIds != null) {
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
val channelKeys = allProgramIds.keys()
while (channelKeys.hasNext()) {
val channelIdStr = channelKeys.next()
val programIdsJson = allProgramIds.optString(channelIdStr)
if (programIdsJson.isBlank()) continue
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
}
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
}
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
// Also handle legacy format (flat { providerId: programId }) for migration
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
}
prefs.edit()
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
return true
}
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
if (sections.length() == 0) {
Log.w(TAG, "synchronize(): no sections in payload")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
Log.d(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
// Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
val prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
)
return true
}
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
}
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
}
// Create a new channel
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
return channelId
}
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it)
}
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it)
val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
try {
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
}
}
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
}
}

View File

@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
when (intent.action) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule");
> =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/**
* Forwards its ref to the underlying native view so it can be used as a

View File

@@ -15,7 +15,6 @@ const WifiSsidModule =
*/
export async function getSSID(): Promise<string | null> {
if (!WifiSsidModule) {
console.log("[WifiSsid] Module not available on this platform");
return null;
}

View File

@@ -17,61 +17,63 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "node scripts/typecheck.js",
"typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
"i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
"dependencies": {
"@bottom-tabs/react-navigation": "1.2.0",
"@douglowder/expo-av-route-picker-view": "^0.0.5",
"@expo/metro-runtime": "~56.0.13",
"@expo/metro-runtime": "~56.0.15",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/ui": "~56.0.17",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2",
"@shopify/flash-list": "2.0.3",
"@tanstack/query-sync-storage-persister": "^5.100.14",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query-persist-client": "^5.100.14",
"axios": "^1.7.9",
"expo": "~56.0.6",
"expo": "~56.0.11",
"expo-application": "~56.0.3",
"expo-asset": "~56.0.15",
"expo-audio": "~56.0.11",
"expo-background-task": "~56.0.15",
"expo-asset": "~56.0.17",
"expo-audio": "~56.0.12",
"expo-background-task": "~56.0.18",
"expo-blur": "~56.0.3",
"expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7",
"expo-constants": "~56.0.16",
"expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8",
"expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16",
"expo-dev-client": "~56.0.20",
"expo-device": "~56.0.4",
"expo-font": "~56.0.5",
"expo-font": "~56.0.6",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.9",
"expo-image": "~56.0.11",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
"expo-linking": "~56.0.14",
"expo-localization": "~56.0.6",
"expo-location": "~56.0.14",
"expo-notifications": "~56.0.14",
"expo-router": "~56.2.7",
"expo-location": "~56.0.17",
"expo-notifications": "~56.0.17",
"expo-router": "~56.2.10",
"expo-screen-orientation": "~56.0.5",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.14",
"expo-sharing": "~56.0.17",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"expo-task-manager": "~56.0.15",
"expo-task-manager": "~56.0.18",
"expo-web-browser": "~56.0.5",
"i18next": "^26.3.0",
"jotai": "2.20.0",
@@ -126,14 +128,16 @@
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.24",
"@types/node": "^18.19.130",
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.7",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "5.9.3"
"tsx": "^4.22.4",
"typescript": "6.0.3"
},
"expo": {
"doctor": {
@@ -141,6 +145,7 @@
"exclude": [
"react-native-google-cast",
"react-native-udp",
"react-native-track-player",
"@jellyfin/sdk"
],
"listUnknownPackages": false

View File

@@ -1,4 +1,4 @@
const { withPodfile } = require("expo/config-plugins");
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch() {
function buildPatch(): string {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch() {
].join("\n");
}
module.exports = function withRuntimeFrameworkHeaders(config) {
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,3 +125,5 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

@@ -1,10 +1,20 @@
const {
import {
type ConfigPlugin,
withAndroidColors,
withAndroidColorsNight,
} = require("expo/config-plugins");
} from "expo/config-plugins";
const withAndroidAlertColors = (config) => {
const setColor = (colorsList, name, value) => {
interface ColorResourceItem {
$: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name,
);
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColors(config, (config) => {
const colors = config.modResults;
const colorsList = colors.resources.color || [];
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList;
return config;
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults;
const colorsList = colors.resources.color || [];
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList;
return config;
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
return config;
};
module.exports = withAndroidAlertColors;
export default withAndroidAlertColors;

View File

@@ -1,8 +1,12 @@
const { withAndroidManifest } = require("expo/config-plugins");
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const _withGoogleCastAndroidManifest = (config) =>
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application[0];
const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
return mod;
});
module.exports = _withGoogleCastAndroidManifest;
export default withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
const { readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { withDangerousMod } = require("expo/config-plugins");
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
withDangerousMod(expoConfig, [
"android",
(modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
},
]);
module.exports = withChangeNativeAndroidTextToWhite;
export default withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
const { withAppBuildGradle } = require("expo/config-plugins");
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
module.exports = function withExcludeMedia3Dash(config) {
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -32,3 +32,5 @@ configurations.all {
return config;
});
};
export default withExcludeMedia3Dash;

View File

@@ -1,6 +1,14 @@
const { withPodfile } = require("@expo/config-plugins");
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const withGitPod = (config, { podName, podspecUrl }) => {
interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
return withPodfile(config, (config) => {
const podfile = config.modResults.contents;
@@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => {
});
};
module.exports = withGitPod;
export default withGitPod;

View File

@@ -1,12 +1,21 @@
const { withGradleProperties } = require("expo/config-plugins");
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
function setGradlePropertiesValue(config, key, value) {
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property = {
const property: AndroidConfig.Properties.PropertiesItem = {
type: "property",
key,
value,
@@ -22,11 +31,14 @@ function setGradlePropertiesValue(config, key, value) {
});
}
module.exports = function withCustomPlugin(config) {
const withCustomGradleProperties: ConfigPlugin = (config) => {
// Expo 52 is not setting this
// 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,
@@ -35,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
);
return config;
};
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
const { withXcodeProject } = require("@expo/config-plugins");
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const withTVOSAppIcon = (config) => {
const withTVOSAppIcon: ConfigPlugin = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => {
});
};
module.exports = withTVOSAppIcon;
export default withTVOSAppIcon;

Some files were not shown because too many files have changed in this diff Show More