Compare commits

..

54 Commits

Author SHA1 Message Date
Fredrik Burmester
fa1c3f3947 chore(eas): pin appleTeamId and ascAppId in submit profiles
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (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 (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
Avoid the interactive Apple team picker and app-existence lookup on
submit by pinning the Individual team (MWD5K362T8) and ASC App ID.
2026-05-31 11:22:59 +02:00
Fredrik Burmester
2761de5a74 chore(eas): use remote app version source with autoIncrement
Switch cli.appVersionSource to remote and enable autoIncrement on all
production profiles so EAS bumps the build number every release instead
of resetting to 1. Remove the dead android.versionCode from app.json and
the unused EAS Update channel (no expo-updates installed).
2026-05-31 11:22:59 +02:00
Fredrik Burmester
feca1d7e9c fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) 2026-05-31 10:50:06 +02:00
Fredrik Burmester
6b6bfd1a89 fix(player): remove white blob artifacts on vertical sliders 2026-05-31 10:48:24 +02:00
Fredrik Burmester
d585b20f49 chore: version 2026-05-31 09:44:05 +02:00
Fredrik Burmester
692ccfdb2c fix(tvos): add arm64 UIRequiredDeviceCapabilities to Top Shelf extension
App Store Connect rejected TestFlight submissions because the Top Shelf
extension binary has a 64-bit slice but did not declare arm64 under
UIRequiredDeviceCapabilities in its Info.plist.
2026-05-31 09:42:09 +02:00
Fredrik Burmester
86e39c444c fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) 2026-05-31 09:37:08 +02:00
Fredrik Burmester
ed7928b4d3 Merge remote-tracking branch 'origin/feat/tv-interface' into develop
Some checks failed
🌐 Translation Sync / sync-translations (push) Waiting to run
🏗️ 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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-30 22:47:33 +02:00
lance chant
27dc7b5664 fix: android pip (#1605)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 22:45:19 +02:00
Alex
a205c75895 chore(mpv-player): Update to MPVKIT 0.41 (#1604) 2026-05-30 22:45:07 +02:00
Fredrik Burmester
a4d4f588fb Merge remote-tracking branch 'origin/chore/sdk-56-migration' into feat/tv-interface
# Conflicts:
#	bun.lock
#	package.json
2026-05-30 21:44:02 +02:00
Fredrik Burmester
aedb7bc51d fix(tv): padding 2026-05-30 21:41:27 +02:00
Fredrik Burmester
5a3e9c51c9 fix(tv): align search skeleton, raise search field, fix up-focus
- Match the loading skeleton to TVSearchSection's scaled layout (poster
  width, item gap, edge padding, heading, poster radius) so placeholders
  line up with the real content.
- Move the native search field up ~50px (drop marginTop).
- Remove the downward focus guide that re-captured upward focus, so
  pressing up from the native search now reaches the tab bar.
2026-05-30 21:21:37 +02:00
Fredrik Burmester
252c58f120 fix(tv): lazy-load @expo/ui to prevent tvOS crash at module load 2026-05-30 21:21:22 +02:00
Fredrik Burmester
d2e73021b1 fix(mpv): apply carried audio/subtitle track after file load
Setting sid/aid before loadfile does not stick for embedded tracks, so a
carried-over subtitle was silently dropped on a freshly loaded episode.
Apply the initial audio/subtitle selection in the MPV_EVENT_FILE_LOADED
handler (after tracks are enumerated) for embedded and external alike, on
both iOS and Android.
2026-05-30 20:15:37 +02:00
Fredrik Burmester
6876ce046f fix(player): retain subtitle language and mode across episodes
Carry the live subtitle/audio selection to the next episode on all TV
navigation paths (next/prev buttons, autoplay) and feed TV subtitle modal
selections back into player state via onSubtitleIndexChange so the chosen
track is what gets carried.

Rank subtitles by language plus forced/hearing-impaired mode, with a
no-language fallback (mode + codec + position), and use an explicit match
flag so a deliberate "off" selection is retained too.
2026-05-30 20:15:24 +02:00
Fredrik Burmester
e044859aaf feat(tv): native tvOS search field via SwiftUI .searchable
Add a local `tv-search` Expo module that hosts SwiftUI's `.searchable`
in a UIHostingController (adapted from expo-tvos-search, minus its native
results grid). It emits typed text to React Native so the existing search
pipeline and custom TV results grid are reused. Handles the RN-tvOS remote
gesture release needed for keyboard input on device.

Wire it into TVSearchPage as a sticky header above the scrollable results,
with a TVFocusGuideView bridge so focus can move from the tab bar into the
native search field.
2026-05-30 16:54:35 +02:00
Fredrik Burmester
c93132177c fix(tv): scale search input font and box with tvTypographyScale setting
The TV search input hardcoded fontSize and box dimensions, so it ignored
the TV display size setting. Drive font, height, padding, and icon from
the scaled `body` typography token so the whole component scales.
2026-05-30 13:29:56 +02:00
Fredrik Burmester
2166bb3867 feat(sync): auto-refresh on Jellyfin LibraryChanged events
Handle the server's LibraryChanged WebSocket message to invalidate
library-dependent React Query caches when items are added/updated/
removed, so newly added episodes/movies appear without a manual
refresh. Debounced to coalesce a scan's burst of events.

Add useRefreshLibraryOnFocus as a fallback that re-checks on screen
focus (throttled, online-only, skips first focus), wired into home
(mobile + TV) and the library pages.
2026-05-30 13:05:43 +02:00
Fredrik Burmester
f9b71ef648 style(login): center server-connect layout and adjust logo position 2026-05-30 12:25:34 +02:00
Fredrik Burmester
d11fb3d0c0 fix(dropdown): use nested Menu submenus for grouped options on iOS
Render titled option groups as nested Menu submenus instead of flat
Pickers, and convert the Discover filters from ContextMenu to Menu.
Keeps single-tap-to-open behavior (ContextMenu requires a long press
and reads as a context menu) while giving the nicer nested grouping.
2026-05-30 12:24:39 +02:00
Fredrik Burmester
dd3ca37108 refactor(settings): convert login-tv card to ListGroup/ListItem 2026-05-30 12:00:45 +02:00
Fredrik Burmester
b588195456 fix(search): correct Library/Discover tab layout for @expo/ui SDK 55
Native Button no longer renders RN <Text> children in SDK 55; use the
label prop. Wrap both buttons in a single Host + HStack with a trailing
Spacer so they sit flush-left with no centering inset.
2026-05-30 11:56:49 +02:00
Fredrik Burmester
fe4d90df26 fix(dropdown): make all stacked dropdowns visible in download sheet
@expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
setStyleSize, so it can't size to content. With the Host's flex:1 height
depending on a zero-size wrapper, a circular dependency collapsed every
selector nested more than one level deep — only the first (Quality) stayed
visible in the download sheet.

Pin the wrapper View to the measured trigger size and let the Host fill it
via absoluteFill, breaking the cycle so Video/Audio/Subtitle render too.
2026-05-30 11:50:03 +02:00
Fredrik Burmester
a190122eea Merge branch 'develop' into feat/tv-interface
# Conflicts:
#	.gitignore
#	app/(auth)/(tabs)/(home)/_layout.tsx
#	components/PlatformDropdown.tsx
#	components/search/DiscoverFilters.tsx
#	components/video-player/controls/BottomControls.tsx
#	components/video-player/controls/TrickplayBubble.tsx
#	modules/mpv-player/ios/MPVLayerRenderer.swift
2026-05-30 11:08:22 +02:00
Fredrik Burmester
0bf8fac079 chore: update pr template
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
2026-05-30 10:41:24 +02:00
Fredrik Burmester
37b51abd34 chore: deps 2026-05-30 10:41:10 +02:00
Fredrik Burmester
6fe464088b fix(mpv): prevent UI freeze on player exit by tearing down mpv off main thread
mpv_terminate_destroy() blocks until mpv's threads (including the
vo_avfoundation output thread) are joined, and that teardown needs the
main run loop to complete. Calling it via queue.sync from MpvPlayerView
deinit (main thread) deadlocked/froze the UI on playback exit.

Remove the wakeup callback synchronously while self is still alive, then
run mpv_terminate_destroy on the serial queue via async so deinit returns
immediately and the main thread is never blocked. Also release the PiP
timebase/controller in deinit.
2026-05-30 10:40:10 +02:00
Fredrik Burmester
769c7a2432 fix: restore nested dropdown sections for expo 55 on iOS using nested ContextMenus 2026-05-30 10:13:40 +02:00
Fredrik Burmester
62c86533b1 fix(settings): preserve app defaults for unlocked plugin values 2026-05-30 10:03:56 +02:00
Fredrik Burmester
4fc78f006d fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
ab0957044f fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
407ef3f51e chore: add skills to gitignore 2026-05-30 10:03:55 +02:00
Fredrik Burmester
0e531da2e0 fix(watchlists): invalidate watchlists list query on add/remove 2026-05-30 10:03:55 +02:00
Lance Chant
1cabbf087e fix: player getting stuck on timer and exit
Fixed a race condition where the upnext countdown started and a user
cancelled/stop the current playback that they would exit the player but
the timer would still be running and then start playing the next episode
and you wouldn't be able to press back or exit out of it

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 10:03:19 +02:00
Gauvain
0f86c776ba feat(player): add chapter markers and chapter list (#1586)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-05-30 09:22:23 +02:00
lostb1t
07b79de203 fix: Do not cache background request for mediasources (#1602) 2026-05-30 09:11:59 +02:00
Gauvain
e4b0161d15 fix(ios): patch react-native-ios-utilities for RN 0.85 prebuilt React
With SwiftUICore resolved (prev commit), the app link surfaced the real blocker:
`Undefined symbols: _OBJC_CLASS_$_RCTRootContentView`, referenced from
react-native-ios-utilities (RCTView+Helpers.o). RCTRootContentView is a legacy
paper class that the prebuilt new-architecture React in RN 0.85 no longer exports.
ios-utilities@5.2.0 is the latest release and still references it, so no version
bump fixes this.

Patch the single offending helper (closestParentReactContentView) to return nil
with an RCTView? type, dropping the only RCTRootContentView reference in the pod.
It feeds only the last-resort touch-handler fallback, moot under the new arch.
Nothing else (incl. react-native-ios-context-menu) references it.

NOTE: react-native-ios-utilities + react-native-ios-context-menu (both Dominic's,
latest 5.2.0 / 3.2.1) are effectively unmaintained for RN 0.85 — candidates for
removal/replacement (context-menu is used only in DiscoverFilters.tsx), like udp.
2026-05-29 18:51:52 +02:00
Gauvain
6e223596f6 fix(ios): drop SwiftUICore autolink on pods so the app links via SwiftUI re-export
Build #9 proved `-weak_framework SwiftUICore` does NOT bypass the allowed-client
check, and applying it to the tvOS app target regressed tvOS — reverted that
plugin (withSwiftUICoreWeakLink).

Confirmed root cause from build #8/#9 logs: both iOS jobs fail at the app
*executable* link (`Ld … Streamyfin`), not at any pod. SwiftUI was split into
SwiftUI + SwiftUICore on iOS 26; the SwiftUI pods emit a `-framework SwiftUICore`
autolink directive that, under use_frameworks :static, is inherited by the app's
static link, and the app isn't an allowed client of the private SwiftUICore.tbd.

Fix: in the pod post_install, compile pods with
`-Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore` so they stop
emitting that direct autolink. SwiftUICore symbols then resolve through SwiftUI's
re-export (SwiftUI.tbd re-exports SwiftUICore). Scoped to phone
(ENV['EXPO_TV'] != '1') to leave the green tvOS build untouched.

Also harden scripts/ios/build-ios.ts: displayBuildError now surfaces the
"Undefined symbols for architecture …" linker block, which the error:-only
pattern filter was swallowing (so unsigned-build failures show the real symbol).
2026-05-29 18:17:02 +02:00
Gauvain
04e75c81a4 fix(ios): weak-link SwiftUICore on app target to bypass Xcode 26 autolink error
Build #8 confirmed BOTH iOS jobs (signed + unsigned) fail at the same step:
the Streamyfin app-target link (`Ld ... Streamyfin`), not any pod framework.
Under use_frameworks static + Xcode 26 the SwiftUI pods' object files carry a
`-framework SwiftUICore` autolink directive that flows into the app link; ld
rejects it with "cannot link directly with 'SwiftUICore' because product being
built is not an allowed client of it".

forceStaticLinking the SwiftUI pods was treating a symptom. The real fix is to
weakly link SwiftUICore on the app target so the allowed-client check is
bypassed and the symbols resolve via SwiftUI's re-export at runtime.

New plugin withSwiftUICoreWeakLink scopes the flag to product-type application
only, leaving the tvOS TopShelf app-extension untouched (a broad weak-link
previously broke that target).
2026-05-29 17:38:01 +02:00
Gauvain
b6ea6d4f14 test(ios): add GlassPoster to forceStaticLinking (local SwiftUI module)
Build #7: forceStaticLinking ExpoUI+GlassEffectView worked (both now static libs) but GlassPoster (local SwiftUI module, modules/glass-poster) was still built as a framework and kept auto-linking SwiftUICore. Add it to the list. [unsigned: GPG]
2026-05-29 16:50:52 +02:00
Gauvain
2c58636843 test(ios): Path A - keep useFrameworks:static + forceStaticLinking ExpoUI/GlassEffectView
Pivot: removing useFrameworks fixed SwiftUICore but broke legacy pods (udp <React/...>), and use_modular_headers! didn't help (prebuilt React VFS). Instead keep useFrameworks:static (udp & all legacy pods keep working) and force-static-link the SwiftUI pods (ExpoUI=@expo/ui, GlassEffectView) so they stop propagating the SwiftUICore framework auto-link to the app target. forceStaticLinking is the documented expo-build-properties fix for Swift pods that break under static frameworks. [unsigned: GPG]
2026-05-29 16:21:43 +02:00
Fredrik Burmester
d9266209d2 Merge remote-tracking branch 'origin/feat/tv-interface' into chore/sdk-56-migration 2026-05-29 08:45:12 +02:00
Gauvain
4cc11403f8 chore(deps): bump Renovate-proven JS dependencies (minors + i18n) (#1599) 2026-05-29 08:36:07 +02:00
Gauvain
0ba3f44615 chore: upgrade Biome to 2.4.16, clean up lint, and fix TV password modal (#1598) 2026-05-29 08:32:21 +02:00
Gauvain
a3ed822bf4 test(ios): use_modular_headers! for legacy pods after dropping useFrameworks
Without useFrameworks:static, old Obj-C pods (react-native-udp -> <React/RCTAssert.h>) lose the React umbrella header. use_modular_headers! restores module header maps so <React/...> resolves for udp and any other legacy pod, in one shot. SwiftUICore already gone (build #5). udp is abandoned (4.1.7, 2023) with no drop-in replacement, so patching headers beats replacing it. [unsigned: GPG]
2026-05-29 08:20:05 +02:00
Gauvain
f8414194f0 test(ios): drop useFrameworks:static (root of SwiftUICore + RNScreens link errors)
useFrameworks:static is the common root of the iOS 26 link failures (SwiftUICore auto-link + the original RNScreens issue) and is broadly broken on SDK 55/56 (expo/expo #44487 etc.). It is NOT mandatory for react-native-google-cast (static Cast SDK works without it). Removing it so all pods build as static libs (the New Arch default). Verifying via CI; google-cast runtime needs device check. [unsigned: GPG unavailable]
2026-05-29 08:01:09 +02:00
Gauvain
1e9c9fb67f revert(ios): drop SwiftUICore weak-link plugin (broke tvOS, no iOS fix)
The -weak_framework SwiftUICore approach did NOT resolve the iOS 26 'cannot link directly with SwiftUICore' link error (auto-linked by a precompiled SwiftUI pod - @expo/ui and/or react-native-glass-effect-view, both in use), and it broke the tvOS TopShelf target (no SwiftUICore on tvOS). Restores tvOS unsigned to green. iOS phone still blocked on the SwiftUICore autolink issue - likely tied to useFrameworks:static + @expo/ui on iOS 26; needs a macOS build to iterate. [unsigned: GPG unavailable while away]
2026-05-29 01:27:57 +02:00
Gauvain
3c7292b73b fix(ios): weak-link SwiftUICore for iOS 26 build (SDK 56)
iOS phone archive failed at Ld: "cannot link directly with 'SwiftUICore' because product being built is not an allowed client of it" (a Liquid Glass / SwiftUI pod pulls SwiftUICore). Add a Podfile config plugin that weak-links SwiftUICore on the app target(s). iOS-only; tvOS unsigned already builds. [unsigned: GPG passphrase unavailable while user away; re-sign on merge]
2026-05-29 00:50:57 +02:00
Gauvain
708d0e8d2e chore(deps): bump safe JS dependencies on SDK 56
JS-only, typecheck-clean, no app source changes:
- lodash 4.17.23 -> 4.18.1 (security)
- @tanstack/react-query 5.90.20 -> 5.100.14 (+ query persisters ^5.100.14)
- jotai 2.16.2 -> 2.20.0
- zod 4.1.13 -> 4.4.3
- i18next ^25 -> ^26.3.0, react-i18next 16.5.3 -> 17.0.8 (already merged on develop)
- @types/lodash 4.17.24

Held back (need app changes / runtime testing): @gorhom/bottom-sheet 5.2.14,
@tanstack/react-pacer 0.22, sonner-native 0.25, react-native-url-polyfill 3.
tailwind/nativewind left untouched on purpose.

[unsigned: GPG passphrase unavailable while user away; re-sign/squash on merge]
2026-05-29 00:44:02 +02:00
Gauvain
74f5844ed7 fix(build): unblock SDK 56 Android + iOS builds
- app.json: kotlinVersion 2.0.21 -> 2.1.20 (Expo SDK 56 modules require >= 2.1.20)
- re-add @react-navigation/native: it is a peer dependency of
  @bottom-tabs/react-navigation and was wrongly removed; its absence broke the
  iOS eager JS bundle (expo export:embed) during the native build.

Local `expo export -p ios` now bundles cleanly. SDK 56 disables USE_FRAMEWORKS
for RNScreens/ReactCodegen, which resolves the original signed-iOS RNScreens
static-frameworks build failure.
2026-05-29 00:16:15 +02:00
Gauvain
38d638cdeb chore(deps): migrate to Expo SDK 56 (Phase 1 - compat)
Compatibility migration from SDK 55 to SDK 56 (react-native-tvos 0.85.3-0,
React 19.2.3). Phase 1 = breaking changes needed to build; new-feature
adoption and TypeScript 6 are deferred to Phase 2.

- Deps aligned to SDK 56 via `expo install --fix` (all expo-* 56.x, screens
  4.25.2, reanimated 4.3.1, worklets 0.8.3, gesture-handler 2.31.x, svg 15.15.4)
- react-native -> react-native-tvos@0.85.3-0; react/react-dom 19.2.3
- expo-router forked React Navigation: ran the SDK 56 codemod
  (@react-navigation/* imports -> expo-router/*), removed the 3 now-unused
  direct @react-navigation/* dependencies, retyped NestedTabPageStack via
  expo-router Stack.Screen options
- StyleSheet.absoluteFillObject -> absoluteFill (removed from RN 0.85 types)
- app.json ios.deploymentTarget 15.6 -> 16.4 (SDK 56 minimum)
- CI: Xcode 26.2 -> 26.4; made xcode-version Renovate-managed via a
  customManager + xcodereleases customDatasource
- @babel/core 7.29.7; dropped version-locked screens/codegen bun-patches
  (no longer applicable on SDK 56)

Deferred to Phase 2: TypeScript 6 (toolchain: @types/node, jest globals,
UdpSocket typing), @expo/vector-icons -> @react-native-vector-icons codemod.

typecheck passes. expo-doctor: 2 known failures remain (react-native-track-player
New Arch fork; typescript major mismatch pending the deferred TS6 bump).
2026-05-28 23:56:03 +02:00
Fredrik Burmester
afe9d33ee4 fix(topshelf): use .tvtopshelf bundle id and app group
The original com.fredrikburmester.streamyfin.TopShelf bundle id and
group.com.fredrikburmester.streamyfin app group were reserved by Apple
(previously created and deleted), so they could not be re-registered.
Switch the extension bundle id and shared app group to .tvtopshelf.
2026-05-28 22:03:09 +02:00
Gauvain
5db4a79e8a ci: enable unsigned tvOS build (signed stays gated on EAS tvOS credentials) 2026-05-28 20:46:21 +02:00
113 changed files with 2916 additions and 1685 deletions

View File

@@ -1,91 +1,54 @@
<!--
Pull Request Template for Streamyfin
====================================
Use this template to help reviewers understand the purpose of your PR
and to ensure all necessary checks are completed before merging.
<!--
Use a conventional commit title for the PR title,
for example `feat(auth): add MFA`
All sections below are required. Write N/A if a section is not applicable.
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
-->
# 📦 Pull Request
## 🔖 Summary
<!--
🤖 AI ASSISTED?
Uncomment the line below if AI was used to assist with this PR:
-->
<!--
[![AI Assisted](https://img.shields.io/badge/AI_Assisted-18181b?style=for-the-badge&logo=openai&logoColor=white)](#) -->
## 📝 Description
<!--
A concise description of the changes introduced by this PR.
Example:
“Add real-time currency conversion widget to dashboard.”
A short description of the changes and why you're making them.
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
-->
## 🏷️ Ticket / Issue
<!--
Link to the related ticket, issue or user story.
You can also indicate if this PR supersedes a previous one.
Example:
- Closes #123
- Fixes STREAMYFIN-456
- Resolves #789
- Supersedes #120
- Related: #130
Example: Fixes #123
-->
## 🛠️ Whats Changed
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
Spec: https://www.conventionalcommits.org/ -->
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
- Scope (optional): e.g., auth, billing, mobile
- Short summary: what changed and why (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
### 🔐 Security & Privacy Impact
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
### ⚡ Performance Impact
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. -->
<!--
Include screenshots of relevant UI changes for both Android and iOS.
Before/After, responsive states (if relevant).
-->
## ✅ Checklist
<!--
Review and check off items as you complete them.
-->
- [ ] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
- [ ] Type checks pass (tsc/biome/etc.)
- [ ] Docs updated (README/ADR/usage/API)
- [ ] No secrets/credentials included; env vars documented
- [ ] Release notes/CHANGELOG entry added (if applicable)
- [ ] Verified locally that changes behave as expected
- [ ] Verified that changes behave as expected for all platforms
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [ ] No secrets, hardcoded credentials, or private config files are included
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
<!--
Describe how reviewers can test your changes.
Describe how reviewers can test your changes. This will help the PR get merged faster.
Example:
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
2. Install deps: `npm|pnpm|yarn|bun install`
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
4. Run tests: `npm|pnpm|yarn|bun test`
5. Verification steps:
- [ ] Expected UI/endpoint behavior
- [ ] Logs show no errors
- [ ] Edge cases covered (list)
1. Open the settings page and scroll to the bottom
2. Verify that the clear data button is visible and pressable
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
-->
## ⚙️ Deployment Notes
<!--
Describe any deployment considerations such as config, environment vars, or native builds.
-->
## 📝 Additional Notes
<!--
Any other information or references related to this PR.
-->

19
.github/renovate.json vendored
View File

@@ -25,6 +25,25 @@
"osvVulnerabilityAlerts": true,
"configMigration": true,
"separateMinorPatch": true,
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
}
],
"customDatasources": {
"xcode": {
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
"format": "json",
"transformTemplates": [
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
]
}
},
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,

View File

@@ -13,7 +13,7 @@ on:
jobs:
build-android-phone:
if: false # [tvos-test] temporarily disabled to only run tvOS builds
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (Phone)
permissions:
@@ -96,7 +96,7 @@ jobs:
retention-days: 7
build-android-tv:
if: false # [tvos-test] temporarily disabled to only run tvOS builds
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
@@ -179,7 +179,7 @@ jobs:
retention-days: 7
build-ios-phone:
if: false # [tvos-test] temporarily disabled to only run tvOS builds
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:
@@ -218,7 +218,8 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -243,7 +244,7 @@ jobs:
retention-days: 7
build-ios-phone-unsigned:
if: false # [tvos-test] temporarily disabled to only run tvOS builds
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
@@ -282,7 +283,8 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
@@ -302,7 +304,7 @@ jobs:
build-ios-tv:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
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:
@@ -341,7 +343,8 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -366,8 +369,9 @@ jobs:
retention-days: 7
build-ios-tv-unsigned:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
# Unsigned tvOS build is enabled (compiles without Apple credentials).
# The signed tvOS job above stays disabled until tvOS provisioning
# profiles are set up in EAS (app + TopShelf targets).
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
@@ -407,7 +411,8 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:

2
.gitignore vendored
View File

@@ -74,3 +74,5 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
.claude/
.agents/skills/**
skills-lock.json

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.54.0",
"version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,6 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
"versionCode": 93,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -78,15 +77,16 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6",
"useFrameworks": "static"
"deploymentTarget": "16.4",
"useFrameworks": "static",
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
"kotlinVersion": "2.1.20",
"minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
@@ -143,8 +143,8 @@
[
"./plugins/withGitPod.js",
{
"podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
}
]
],

View File

@@ -16,7 +16,7 @@ export interface MenuLink {
icon: string;
}
export default function menuLinks() {
export default function CustomLinksPage() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);

View File

@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() {
export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);

View File

@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
@@ -47,15 +48,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -66,15 +59,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -104,15 +89,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -123,15 +100,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -142,15 +111,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -161,15 +122,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -180,15 +133,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -199,15 +144,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -237,15 +174,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -256,15 +185,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -275,15 +196,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -294,15 +207,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -313,15 +218,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -332,15 +229,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
@@ -351,15 +240,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -369,11 +250,7 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerLeft: () => <HeaderBackButton />,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",

View File

@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
export default function page() {
export default function DownloadsPage() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);

View File

@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
export default function page() {
export default function SessionsPage() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
};
const getProgressPercentage = () => {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
if (!session.NowPlayingItem?.RunTimeTicks) {
return 0;
}

View File

@@ -59,17 +59,17 @@ function SettingsMobile() {
<QuickConnect className='mb-4' />
<TouchableOpacity
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
>
<Text className='text-white font-bold text-base mb-1'>
{t("pairing.pair_with_phone_title")}
</Text>
<Text className='text-neutral-400 text-sm'>
{t("pairing.pair_with_phone_description")}
</Text>
</TouchableOpacity>
<View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
onPress={() =>
router.push("/(auth)/(tabs)/(home)/companion-login")
}
title={t("pairing.pair_with_phone")}
textColor='blue'
/>
</ListGroup>
</View>
<View className='mb-4'>
<AppLanguageSelector />

View File

@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function AppearanceHideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function HideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();

View File

@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function MarlinSearchPage() {
const navigation = useNavigation();
const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function StreamystatsPage() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();

View File

@@ -37,8 +37,10 @@ const Page: React.FC = () => {
ItemFields.MediaStreams,
]);
// Lazily preload item with full media sources in background
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
// Lazily preload item with full media sources in background — never cache
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
gcTime: 0,
});
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {

View File

@@ -13,7 +13,7 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function page() {
export default function JellyseerrCompanyPage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function page() {
export default function JellyseerrGenrePage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function page() {
export default function JellyseerrPersonPage() {
const local = useLocalSearchParams();
const { t } = useTranslation();

View File

@@ -1,13 +1,13 @@
import { Slot, Stack, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
} from "expo-router/js-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Slot, Stack, withLayoutContext } from "expo-router";
} from "expo-router/react-navigation";
import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
export default function LiveTvChannelsPage() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets();

View File

@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() {
export default function LiveTvGuidePage() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export default function page() {
export default function LiveTvRecordingsPage() {
const { t } = useTranslation();
return (
<View className='flex items-center justify-center h-full -mt-12'>

View File

@@ -40,6 +40,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -104,6 +105,10 @@ const Page = () => {
const { orientation } = useOrientation();
// Fallback refresh for newly added content when returning to the library
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();

View File

@@ -1,13 +1,13 @@
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
} from "expo-router/js-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
} from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -1,8 +1,8 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,8 +1,8 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useNavigation, useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -66,7 +66,7 @@ const exampleSearches = [
"The Mandalorian",
];
export default function search() {
export default function SearchPage() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -221,7 +221,7 @@ export default function search() {
const ids = response1.data.ids;
if (!ids || !ids.length) {
if (!ids?.length) {
return [];
}

View File

@@ -3,11 +3,11 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
} from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";

View File

@@ -63,7 +63,7 @@ import { writeToLog } from "@/utils/log";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function page() {
export default function DirectPlayerPage() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -185,11 +185,11 @@ export default function page() {
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params
// Initialize TV audio/subtitle indices from URL params.
// No undefined guard: when a new episode's URL omits audioIndex, reset to
// undefined (media default) rather than leaking the previous episode's track.
useEffect(() => {
if (audioIndex !== undefined) {
setCurrentAudioIndex(audioIndex);
}
setCurrentAudioIndex(audioIndex);
}, [audioIndex]);
useEffect(() => {
@@ -317,7 +317,7 @@ export default function page() {
}
let result: Stream | null = null;
if (offline && downloadedItem && downloadedItem.mediaSource) {
if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath;
if (item) {
result = {
@@ -470,8 +470,11 @@ export default function page() {
return {
ItemId: item.Id,
AudioStreamIndex: audioIndex ? audioIndex : undefined,
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
// Report the live selection so server-side session/resume state reflects
// mid-playback track changes. Note: index 0 is valid (don't treat as
// falsy); -1 means "off" and is reported as-is.
AudioStreamIndex: currentAudioIndex,
SubtitleStreamIndex: currentSubtitleIndex,
MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying,
@@ -485,8 +488,8 @@ export default function page() {
}, [
stream,
item?.Id,
audioIndex,
subtitleIndex,
currentAudioIndex,
currentSubtitleIndex,
mediaSourceId,
progress,
isPlaying,
@@ -553,8 +556,8 @@ export default function page() {
},
[
item?.Id,
audioIndex,
subtitleIndex,
currentAudioIndex,
currentSubtitleIndex,
mediaSourceId,
isPlaying,
stream,
@@ -822,12 +825,10 @@ export default function page() {
],
);
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) {
_setShowControls(false);
}
@@ -845,6 +846,9 @@ export default function page() {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.();
}, []);
@@ -1009,8 +1013,9 @@ export default function page() {
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
@@ -1029,8 +1034,8 @@ export default function page() {
}, [
previousItem,
settings,
subtitleIndex,
audioIndex,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
@@ -1075,8 +1080,10 @@ export default function page() {
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(nextItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
// Use the live selection (updated when the user changes tracks
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
@@ -1095,8 +1102,8 @@ export default function page() {
}, [
nextItem,
settings,
subtitleIndex,
audioIndex,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
@@ -1247,6 +1254,7 @@ export default function page() {
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);

View File

@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: scaleSize(14),
justifyContent: "center",

View File

@@ -2,12 +2,12 @@ import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
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 { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
@@ -395,8 +395,9 @@ function Layout() {
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === "success";
return (
query.state.status === "success" && query.options.gcTime !== 0
);
},
},
}}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"files": {
"includes": [
"**/*",

View File

@@ -1,29 +0,0 @@
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
* must have a single output. More files => more genrule()s => slower builds.
*/
-#ifndef __cplusplus
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
-#endif
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
+#if defined(__cplusplus)
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
#ifndef ${headerFileNameWithNoExt}_H
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
structInlineMethods +
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
`#endif // ${headerFileNameWithNoExt}_H` +
- '\n'
+ '\n#endif // defined(__cplusplus)\n'
);
};
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**

View File

@@ -1,191 +0,0 @@
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -34,6 +34,11 @@
#import "integrations/RNSDismissibleModalProtocol.h"
#import "utils/UINavigationBar+RNSUtility.h"
+#if TARGET_OS_TV
+#import <React/RCTTVNavigationEventNotification.h>
+#import <React/RCTTVRemoteHandler.h>
+#endif // TARGET_OS_TV
+
#ifdef RNS_GAMMA_ENABLED
#import "RNSFrameCorrectionProvider.h"
#import "Swift-Bridging.h"
@@ -43,6 +48,12 @@
namespace react = facebook::react;
#endif // RCT_NEW_ARCH_ENABLED
+#if TARGET_OS_TV
+@interface RNSNavigationController ()
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
+@end
+#endif // TARGET_OS_TV
+
@interface RNSScreenStackView () <
UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate,
@@ -62,6 +73,57 @@ namespace react = facebook::react;
@implementation RNSNavigationController
+#if TARGET_OS_TV
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+
+ self.rnscreens_menuGestureRecognizer =
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
+
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_enableMenuGesture)
+ name:RCTTVEnableMenuKeyNotification
+ object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_disableMenuGesture)
+ name:RCTTVDisableMenuKeyNotification
+ object:nil];
+
+ if ([RCTTVRemoteHandler useMenuKey]) {
+ [self rnscreens_enableMenuGesture];
+ }
+}
+
+- (void)dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (void)rnscreens_enableMenuGesture
+{
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_disableMenuGesture
+{
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
+{
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
+ keyAction:recognizer.eventKeyAction
+ tag:nil
+ target:nil];
+}
+#endif // TARGET_OS_TV
+
#if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle
{
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
maxWidth: splitView.maximumSupplementaryColumnWidth)
#if compiler(>=6.2)
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
validateColumnConstraints(
minWidth: splitView.minimumInspectorColumnWidth,
maxWidth: splitView.maximumInspectorColumnWidth)
}
+ #endif
#endif
// Step 2.2 - applying updates to columns
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
}
#if compiler(>=6.2)
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
if splitView.minimumSecondaryColumnWidth >= 0 {
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
splitView.preferredInspectorColumnWidthOrFraction
}
}
+ #endif
#endif
// Step 2.3 - manipulating with inspector column
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
///
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
-
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
let inspector = inspectors.first
if inspector != nil {
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
setViewController(inspectorViewController, for: .inspector)
}
}
+ #endif
}
///
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
///
func maybeShowInspector() {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
show(.inspector)
}
+ #endif
}
///
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
///
func maybeHideInspector() {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
hide(.inspector)
}
+ #endif
}
}
#endif
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
public func splitViewController(
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
) {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
}
}
}
+ #endif
}
#endif

700
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
@@ -199,9 +200,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
const downloadDetailsPromises = items.map(async (item) => {
// Ensure the snapshot we store offline carries the Chapters array.
// Page-level fetches sometimes use a fields filter that omits it; the
// offline player would then render no chapter ticks / list.
let itemForDownload = item;
if (!itemForDownload.Chapters && itemForDownload.Id) {
try {
const enriched = await getUserLibraryApi(api).getItem({
itemId: itemForDownload.Id,
userId: user.Id!,
});
if (enriched.data) {
itemForDownload = enriched.data;
}
} catch (e) {
console.warn(
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
e,
);
}
}
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
? getDefaultPlaySettings(itemForDownload, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
@@ -210,7 +232,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const downloadDetails = await getDownloadUrl({
api,
item,
item: itemForDownload,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
@@ -222,7 +244,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return {
url: downloadDetails?.url,
item,
item: itemForDownload,
mediaSource: downloadDetails?.mediaSource,
};
});

View File

@@ -1,11 +1,3 @@
import {
Button,
Host,
Menu,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
@@ -20,6 +12,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Option types
export type RadioOption<T = any> = {
type: "radio";
@@ -261,25 +264,29 @@ const PlatformDropdownComponent = ({
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<View>
{/* Hidden measurer: lays the trigger out normally to capture its
intrinsic size, which we then pin onto the Host below. */}
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
<View
style={{ alignSelf: "flex-start" }}
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
</View>
<Host
style={[
triggerSize ?? { opacity: 0 },
expoUIConfig?.hostStyle as any,
]}
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
@@ -295,41 +302,40 @@ const PlatformDropdownComponent = ({
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options.
// Use the option index (a stable primitive) as the
// tag/selection value and React key. Option `value`s can be
// objects (e.g. bitrate / media source), which collapse to
// "[object Object]" as a key and never match the Picker's
// primitive selection.
const selectedRadioIndex = radioOptions.findIndex(
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
selection={
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
}
onSelectionChange={(index) => {
const selectedOption = radioOptions[index as number];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
>
{radioOptions.map((opt, optionIndex) => (
<SwiftUIText
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
modifiers={[tag(optionIndex)]}
>
{opt.label}
</SwiftUIText>
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Picker>,
</Menu>,
);
} else {
// Render radio options as direct buttons

View File

@@ -414,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -0,0 +1,196 @@
/**
* A modal listing an item's chapters. Each row shows the chapter name and its
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
* chapter and closes the modal. Player-agnostic — the seek is injected.
*/
import { Ionicons } from "@expo/vector-icons";
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { memo, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import {
type ChapterEntry,
chapterStartsMs,
formatChapterTime,
sortedChapters,
} from "@/utils/chapters";
interface ChapterListProps {
visible: boolean;
chapters: ChapterInfo[] | null | undefined;
/** Current playback position in milliseconds (to highlight the row). */
currentPositionMs: number;
/** Seek the player to this millisecond position. */
onSeek: (positionMs: number) => void;
onClose: () => void;
}
const ROW_HEIGHT = 48;
function ChapterListComponent({
visible,
chapters,
currentPositionMs,
onSeek,
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
// every tick — chapters is the only input that drives the underlying array.
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
const activeIndex = useMemo(() => {
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (currentPositionMs >= starts[i]) idx = i;
else break;
}
return idx;
}, [currentPositionMs, starts]);
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
// children mounted across visible toggles, so subsequent opens never scroll.
// Trigger an imperative scroll each time the sheet becomes visible.
useEffect(() => {
if (!visible || activeIndex < 0 || entries.length === 0) return;
const raf = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({
index: activeIndex,
animated: false,
viewPosition: 0.5,
});
});
return () => cancelAnimationFrame(raf);
}, [visible, activeIndex, entries.length]);
return (
<Modal
visible={visible}
transparent
animationType='slide'
onRequestClose={onClose}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
onPress={onClose}
hitSlop={10}
accessibilityRole='button'
accessibilityLabel={t("chapters.close")}
>
<Ionicons name='close' size={24} color={Colors.text} />
</Pressable>
</View>
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
onScrollToIndexFailed={(info) => {
// Required when getItemLayout is provided and the target index
// is outside the currently rendered window. Fallback to an
// offset-based scroll, then retry the precise scroll once a
// frame has elapsed.
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: false,
});
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
animated: false,
viewPosition: 0.5,
});
}, 50);
}}
renderItem={({ item, index }) => {
const positionMs = item.positionMs;
const isActive = index === activeIndex;
return (
<Pressable
onPress={() => {
onSeek(positionMs);
onClose();
}}
style={[
styles.row,
isActive && { backgroundColor: `${Colors.primary}33` },
]}
>
<Text
style={[
styles.rowText,
{ color: isActive ? Colors.primary : Colors.text },
]}
numberOfLines={1}
>
{item.chapter.Name ||
t("chapters.chapter_number", { number: index + 1 })}
</Text>
<Text style={styles.rowTime}>
{formatChapterTime(positionMs)}
</Text>
</Pressable>
);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
}
export const ChapterList = memo(ChapterListComponent);
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
},
title: {
color: Colors.text,
fontSize: 17,
fontWeight: "700",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
height: ROW_HEIGHT,
},
rowText: {
fontSize: 15,
flex: 1,
},
rowTime: {
color: Colors.icon,
fontSize: 13,
marginLeft: 12,
},
});

View File

@@ -0,0 +1,87 @@
/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);

View File

@@ -10,6 +10,7 @@ import {
type TextInputProps,
View,
} from "react-native";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface InputProps extends TextInputProps {
extraClassName?: string;
@@ -20,6 +21,9 @@ export function Input(props: InputProps) {
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
// TV-only: scales the input font with the tvTypographyScale setting.
// Not consumed by the mobile branch below.
const tvTypography = useScaledTVTypography();
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
@@ -41,8 +45,18 @@ export function Input(props: InputProps) {
};
if (Platform.isTV) {
// Scale the whole input (box height, padding, icon) proportionally with the
// font so the component grows/shrinks with the tvTypographyScale setting.
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
const fontSize = tvTypography.body;
const factor = fontSize / 28;
const height = Math.round(56 * factor);
const paddingLeft = Math.round(24 * factor);
const iconSize = Math.round(26 * factor);
const iconMarginRight = Math.round(14 * factor);
const containerStyle = {
height: 48,
height,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
@@ -51,16 +65,16 @@ export function Input(props: InputProps) {
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft: 16,
paddingLeft,
};
const inputElement = (
<>
<Ionicons
name='search'
size={20}
size={iconSize}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: 12 }}
style={{ marginRight: iconMarginRight }}
/>
<TextInput
ref={inputRef}
@@ -69,8 +83,8 @@ export function Input(props: InputProps) {
style={[
{
flex: 1,
height: 48,
fontSize: 18,
height,
fontSize,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",

View File

@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}, [process?.progress]);
// Return null after all hooks have been called
if (!process || !process.item || !process.item.Id) {
if (!process?.item?.Id) {
return null;
}

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -89,6 +90,10 @@ const HomeMobile = () => {
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");

View File

@@ -35,6 +35,7 @@ import { Loader } from "@/components/Loader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import {
@@ -86,6 +87,10 @@ export const Home = () => {
const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
// Get subtitle for episodes
const episodeSubtitle = useMemo(() => {
if (!activeItem || activeItem.Type !== "Episode") return null;
if (activeItem?.Type !== "Episode") return null;
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
}, [activeItem]);

View File

@@ -180,4 +180,4 @@ const styles = StyleSheet.create({
},
});
export { CARD_WIDTH, CARD_HEIGHT };
export { CARD_HEIGHT, CARD_WIDTH };

View File

@@ -155,7 +155,7 @@ export const TVLiveTVGuide: React.FC = () => {
);
// Fetch programs for visible channels
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
const { data: programsData } = useQuery({
queryKey: [
"livetv",
"tv-guide",

View File

@@ -382,16 +382,18 @@ export const Login: React.FC = () => {
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full'>
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
alignSelf: "center",
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
@@ -429,8 +431,6 @@ export const Login: React.FC = () => {
await handleConnect(server.address);
}}
/>
</View>
<View className='px-4 pb-2'>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);

View File

@@ -14,6 +14,7 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
interface TVPasswordEntryModalProps {
@@ -201,6 +202,13 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
setIsReady(false);
}, [visible]);
// Close the modal on the TV remote back/menu button while it is open.
useTVBackPress(() => {
if (!visible) return false;
onClose();
return true;
}, [visible, onClose]);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));

View File

@@ -1,15 +1,17 @@
import {
Button,
ContextMenu,
Host,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
@@ -35,7 +37,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<Host
style={{
@@ -47,42 +49,54 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Menu
label={
<Button
modifiers={[buttonStyle("glass")]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
selection={jellyseerrOrderBy as unknown as string}
onSelectionChange={(value) => {
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
}}
>
{sortOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
</SwiftUIText>
))}
</Picker>
<Picker
label={t("library.filters.sort_order")}
selection={jellyseerrSortOrder}
onSelectionChange={(value) => {
setJellyseerrSortOrder(value as "asc" | "desc");
}}
>
{orderOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`library.filters.${item}`)}
</SwiftUIText>
))}
</Picker>
</ContextMenu.Items>
</ContextMenu>
/>
}
>
<Menu
label={`${t("library.filters.sort_by")}: ${t(
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
)}`}
>
{sortOptions.map((item) => {
const isSelected =
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
return (
<Button
key={item}
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() =>
setJellyseerrOrderBy(
item as unknown as JellyseerrSearchSort,
)
}
/>
);
})}
</Menu>
<Menu
label={`${t("library.filters.sort_order")}: ${t(
`library.filters.${jellyseerrSortOrder}`,
)}`}
>
{orderOptions.map((item) => {
const isSelected = jellyseerrSortOrder === item;
return (
<Button
key={item}
label={t(`library.filters.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() => setJellyseerrSortOrder(item)}
/>
);
})}
</Menu>
</Menu>
</Host>
);
}

View File

@@ -1,9 +1,16 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Tag } from "@/components/GenreTags";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, HStack, Spacer } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
@@ -17,18 +24,10 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
setSearchType,
t,
}) => {
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Host style={{ height: 40, flex: 1 }}>
<HStack spacing={8}>
<Button
modifiers={[
buttonStyle(
@@ -36,19 +35,8 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
),
]}
onPress={() => setSearchType("Library")}
>
<Text>{t("search.library")}</Text>
</Button>
</Host>
<Host
style={{
height: 40,
width: 100,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
label={t("search.library")}
/>
<Button
modifiers={[
buttonStyle(
@@ -56,11 +44,11 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
),
]}
onPress={() => setSearchType("Discover")}
>
<Text>{t("search.discover")}</Text>
</Button>
</Host>
</>
label={t("search.discover")}
/>
<Spacer />
</HStack>
</Host>
);
}

View File

@@ -4,10 +4,11 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TvSearchView } from "@/modules/tv-search";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
@@ -16,36 +17,46 @@ import type {
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { scaleSize } from "@/utils/scaleSize";
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
// here — not the whole keyboard. Tunable once seen on device.
const SEARCH_AREA_HEIGHT = 250;
const SECTION_GAP = 10;
const SCALE_PADDING = 20;
// Loading skeleton for TV
// Loading skeleton for TV.
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
// padding, heading typography, poster radius) so the placeholder lines up with
// the real content that replaces it.
const TVLoadingSkeleton: React.FC = () => {
const typography = useScaledTVTypography();
const itemWidth = 210;
const sizes = useScaledTVSizes();
const itemWidth = sizes.posters.poster;
return (
<View style={{ overflow: "visible" }}>
{/* Section header placeholder — matches the heading typography + margins */}
<View
style={{
width: 200,
height: 28,
width: itemWidth,
height: typography.heading,
backgroundColor: "#262626",
borderRadius: 8,
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
}}
/>
<View
style={{
flexDirection: "row",
gap: 16,
paddingHorizontal: SCALE_PADDING,
gap: sizes.gaps.item,
paddingLeft: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
@@ -56,15 +67,14 @@ const TVLoadingSkeleton: React.FC = () => {
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(24),
marginBottom: scaleSize(8),
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
@@ -124,7 +134,6 @@ interface TVSearchPageProps {
}
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
search,
setSearch,
debouncedSearch,
movies,
@@ -215,125 +224,129 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Input */}
<View style={{ flex: 1 }}>
{/* Sticky header: search field stays pinned while results scroll below. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING + 200,
paddingTop: insets.top + TOP_PADDING,
}}
>
<Input
placeholder={t("search.search")}
value={search}
onChangeText={setSearch}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
hasTVPreferredFocus={
debouncedSearch.length === 0 &&
sections.length === 0 &&
!showDiscover
}
/>
</View>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
{/* 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. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
)}
</View>
{/* Loading State */}
{currentLoading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
/>
))}
</View>
)}
</View>
)}
{/* Jellyseerr/Discover Search Results */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
<TVJellyseerrSearchResults
movieResults={jellyseerrMovies}
tvResults={jellyseerrTv}
personResults={jellyseerrPersons}
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}
/>
)}
{/* Loading State */}
{currentLoading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* Discover Content (when no search query in Discover mode) */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
<TVDiscover sliders={discoverSliders} />
)}
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
/>
))}
</View>
)}
{/* No Results State */}
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>
)}
</ScrollView>
{/* Jellyseerr/Discover Search Results */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
<TVJellyseerrSearchResults
movieResults={jellyseerrMovies}
tvResults={jellyseerrTv}
personResults={jellyseerrPersons}
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}
/>
)}
{/* Discover Content (when no search query in Discover mode) */}
{isDiscoverMode &&
!jellyseerrLoading &&
debouncedSearch.length === 0 && (
<TVDiscover sliders={discoverSliders} />
)}
{/* No Results State */}
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>
)}
</ScrollView>
</View>
);
};

View File

@@ -20,6 +20,8 @@ interface TVSearchSectionProps extends ViewProps {
onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** Override the horizontal edge padding (defaults to the scaled TV padding). */
horizontalPadding?: number;
}
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
@@ -31,12 +33,14 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
onItemPress,
onItemLongPress,
imageUrlGetter,
horizontalPadding,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
@@ -273,7 +277,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
marginLeft: edgePadding,
letterSpacing: 0.5,
}}
>
@@ -294,10 +298,10 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
}}

View File

@@ -193,7 +193,7 @@ const TVSeasonButton: React.FC<{
>
<Text
style={{
fontSize: typography.body,
fontSize: typography.callout,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "bold",
}}
@@ -538,7 +538,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/>
<Text
style={{
fontSize: typography.body,
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}

View File

@@ -1,14 +1,9 @@
import type { ParamListBase, RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { Platform } from "react-native";
import { HeaderBackButton } from "../common/HeaderBackButton";
type ICommonScreenOptions =
| NativeStackNavigationOptions
| ((prop: {
route: RouteProp<ParamListBase, string>;
navigation: any;
}) => NativeStackNavigationOptions);
type ICommonScreenOptions = ComponentProps<typeof Stack.Screen>["options"];
export const commonScreenOptions: ICommonScreenOptions = {
title: "",

View File

@@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
const typography = useScaledTVTypography();
const { t } = useTranslation();
const progress = useSharedValue(0);
const cancelled = useSharedValue(false);
const onFinishRef = useRef(onFinish);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
@@ -120,13 +121,15 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
return;
}
cancelled.value = false;
// Resume from current position
const remainingDuration = (1 - progress.value) * 8000;
progress.value = withTiming(
1,
{ duration: remainingDuration, easing: Easing.linear },
(finished) => {
if (finished) {
if (finished && !cancelled.value) {
runOnJS(onFinishRef.current)();
}
},
@@ -134,9 +137,10 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
// Cancel animation on unmount to prevent onFinish from firing after exit
return () => {
cancelled.value = true;
cancelAnimation(progress);
};
}, [show, isPlaying, progress]);
}, [show, isPlaying, progress, cancelled]);
const progressStyle = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,

View File

@@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: scaleSize(14),
justifyContent: "center",

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -1,19 +1,34 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { ChapterMarkers } from "./ChapterMarkers";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble";
// Chapter tick height in dp — matches the slider track height for a clean,
// flush look (no top/bottom overflow).
const TICK_HEIGHT = 10;
interface BottomControlsProps {
item: BaseItemDto;
/** Item chapters, used for the tick overlay and chapter list. */
chapters?: ChapterInfo[] | null;
/** Total media duration in milliseconds. */
durationMs: number;
showControls: boolean;
isSliding: boolean;
showRemoteBubble: boolean;
@@ -39,6 +54,8 @@ interface BottomControlsProps {
handleSliderChange: (value: number) => void;
handleTouchStart: () => void;
handleTouchEnd: () => void;
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
seekTo: (value: number) => void;
// Trickplay props
trickPlayUrl: {
@@ -65,6 +82,8 @@ interface BottomControlsProps {
export const BottomControls: FC<BottomControlsProps> = ({
item,
chapters,
durationMs,
showControls,
isSliding,
showRemoteBubble,
@@ -88,13 +107,39 @@ export const BottomControls: FC<BottomControlsProps> = ({
handleSliderChange,
handleTouchStart,
handleTouchEnd,
seekTo,
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
const chapterMarkerList = useMemo(
() => chapterMarkers(chapters, durationMs),
[chapters, durationMs],
);
const hasChapters = chapterMarkerList.length > 1;
// Current chapter name for the always-visible header label (live playback).
const currentChapterName = useMemo(
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
[hasChapters, currentTime, chapters],
);
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
// an {h,m,s} object derived from the slider's dragged value — convert back
// to ms for the lookup. Only useful while actively scrubbing.
const scrubChapterName = useMemo(() => {
if (!hasChapters) return null;
const scrubMs =
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
return chapterNameAt(scrubMs, chapters);
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
return (
<View
@@ -136,8 +181,24 @@ export const BottomControls: FC<BottomControlsProps> = ({
{item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
{currentChapterName ? (
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
{currentChapterName}
</Text>
) : null}
</View>
<View className='flex flex-row space-x-2 shrink-0'>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -181,7 +242,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
position: "relative",
// Allow chapter ticks taller than the 10px track to bleed out
// top/bottom (RN defaults to overflow: "hidden" on Android).
overflow: "visible",
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -209,6 +272,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
chapterName={scrubChapterName}
/>
)
}
@@ -218,7 +282,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min}
maximumValue={max}
/>
<ChapterMarkers chapterPositions={chapterPositions} />
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
</View>
<TimeDisplay
currentTime={currentTime}
@@ -226,6 +290,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
/>
</View>
</View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View>
);
};

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -267,6 +267,7 @@ export const Controls: FC<Props> = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
} = useVideoSlider({
progress,
isSeeking,
@@ -555,6 +556,8 @@ export const Controls: FC<Props> = ({
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
@@ -578,6 +581,7 @@ export const Controls: FC<Props> = ({
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}

View File

@@ -59,6 +59,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { TrickplayBubble } from "./TrickplayBubble";
import type { Track } from "./types";
import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
@@ -232,14 +233,8 @@ export const Controls: FC<Props> = ({
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
const router = useRouter();
const {
bitrateValue,
subtitleIndex: paramSubtitleIndex,
audioIndex: paramAudioIndex,
} = useLocalSearchParams<{
const { bitrateValue } = useLocalSearchParams<{
bitrateValue: string;
subtitleIndex: string;
audioIndex: string;
}>();
const { nextItem: internalNextItem } = usePlaybackManager({
@@ -315,6 +310,31 @@ export const Controls: FC<Props> = ({
[onSubtitleIndexChange],
);
// Re-fetch subtitle streams from the server (e.g. after a server-side
// download) and map them to the modal's Track shape. setTrack drives the
// player through the same handler used for manual subtitle selection.
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
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
.filter((stream) => typeof stream.Index === "number")
.map((stream) => {
const index = stream.Index as number;
return {
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index,
setTrack: () => onSubtitleIndexChange?.(index),
};
});
} catch {
return [];
}
}, [onRefreshSubtitleTracks, onSubtitleIndexChange]);
const {
trickPlayUrl,
calculateTrickplayUrl,
@@ -491,6 +511,8 @@ export const Controls: FC<Props> = ({
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
() => {},
);
const exitingRef = useRef(false);
const [isExiting, setIsExiting] = useState(false);
const updateSeekBubbleTime = useCallback((ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
@@ -555,10 +577,22 @@ export const Controls: FC<Props> = ({
const handleOpenSubtitleSheet = useCallback(() => {
setLastOpenedModal("subtitle");
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option
const tracksWithoutDisable = (videoContextSubtitleTracks ?? []).filter(
(track) => track.index !== -1,
);
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option.
// Wrap each setTrack so selecting a subtitle ALSO updates the player's live
// index via onSubtitleIndexChange. The modal is a separate route, so the
// VideoContext router.setParams inside setTrack targets the modal — not the
// player — leaving currentSubtitleIndex stale. Without this sync, the next
// episode carries the previously-shown subtitle instead of the one the user
// just picked. (The audio sheet already uses onAudioIndexChange directly.)
const tracksWithoutDisable = (videoContextSubtitleTracks ?? [])
.filter((track) => track.index !== -1)
.map((track) => ({
...track,
setTrack: () => {
track.setTrack();
onSubtitleIndexChange?.(track.index);
},
}));
showSubtitleModal({
item,
mediaSourceId: mediaSource?.Id,
@@ -570,8 +604,12 @@ export const Controls: FC<Props> = ({
(t) => t.index === -1,
);
disableTrack?.setTrack();
onSubtitleIndexChange?.(-1);
},
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
refreshSubtitleTracks: onRefreshSubtitleTracks
? refreshSubtitleTracks
: undefined,
});
controlsInteractionRef.current();
}, [
@@ -580,7 +618,10 @@ export const Controls: FC<Props> = ({
mediaSource?.Id,
videoContextSubtitleTracks,
subtitleIndex,
onSubtitleIndexChange,
handleLocalSubtitleDownloaded,
onRefreshSubtitleTracks,
refreshSubtitleTracks,
]);
const handleToggleTechnicalInfo = useCallback(() => {
@@ -929,6 +970,16 @@ export const Controls: FC<Props> = ({
router.back();
}, [router]);
const handleWillExit = useCallback(() => {
exitingRef.current = true;
setIsExiting(true);
}, []);
const handleCancelExit = useCallback(() => {
exitingRef.current = false;
setIsExiting(false);
}, []);
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls: showControls,
toggleControls,
@@ -945,6 +996,8 @@ export const Controls: FC<Props> = ({
onVerticalDpad: handleVerticalDpad,
onHideControls: hideControls,
onBack: handleBack,
onWillExit: handleWillExit,
onCancelExit: handleCancelExit,
videoTitle: item?.Name ?? undefined,
});
@@ -986,13 +1039,12 @@ export const Controls: FC<Props> = ({
return;
}
// Use the live selection passed down from the player (currentSubtitleIndex
// / currentAudioIndex), not the stale URL params the episode started with.
// This path runs on autoplay; the manual "Next" button uses goToNextItemProp.
const previousIndexes = {
subtitleIndex: paramSubtitleIndex
? Number.parseInt(paramSubtitleIndex, 10)
: undefined,
audioIndex: paramAudioIndex
? Number.parseInt(paramAudioIndex, 10)
: undefined,
subtitleIndex,
audioIndex,
};
const {
@@ -1019,8 +1071,8 @@ export const Controls: FC<Props> = ({
[
nextItem,
settings,
paramSubtitleIndex,
paramAudioIndex,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
@@ -1030,6 +1082,7 @@ export const Controls: FC<Props> = ({
goToNextItemRef.current = goToNextItem;
const handleAutoPlayFinish = useCallback(() => {
if (exitingRef.current) return;
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
@@ -1104,7 +1157,7 @@ export const Controls: FC<Props> = ({
nextItem={nextItem}
api={api}
show={isCountdownActive}
isPlaying={isPlaying}
isPlaying={isPlaying && !isExiting}
onFinish={handleAutoPlayFinish}
onPlayNext={handleNextItemButton}
controlsVisible={showControls}
@@ -1408,14 +1461,14 @@ export const Controls: FC<Props> = ({
const styles = StyleSheet.create({
controlsContainer: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
darkOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
focusStealingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
zIndex: 1,
},
bottomContainer: {

View File

@@ -4,7 +4,9 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
const BASE_IMAGE_SCALE = 1.4;
// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text
// more room and feel closer to the Jellyfin web style.
const BASE_IMAGE_SCALE = 1.6;
const BUBBLE_LEFT_OFFSET = 62;
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
@@ -28,12 +30,8 @@ interface TrickplayBubbleProps {
};
/** Scale factor for the image (default 1). Does not affect timestamp text. */
imageScale?: number;
}
function formatTime(hours: number, minutes: number, seconds: number): string {
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const prefix = hours > 0 ? `${hours}:` : "";
return `${prefix}${pad(minutes)}:${pad(seconds)}`;
/** Chapter name at the scrubbed position, if any. */
chapterName?: string | null;
}
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
@@ -41,6 +39,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
trickplayInfo,
time,
imageScale = 1,
chapterName,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -49,19 +48,28 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
<View
style={{
position: "absolute",
// Sit just above the slider — high enough not to overlap the
// progress bar, low enough to feel anchored to the thumb.
left: -BUBBLE_LEFT_OFFSET * imageScale,
bottom: 0,
paddingTop: 30,
paddingTop: 12,
paddingBottom: 5,
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
justifyContent: "center",
alignItems: "center",
// Bring the bubble in front of the player title / overlays.
zIndex: 999,
elevation: 10,
}}
>
<View
@@ -89,10 +97,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
source={{ uri: url }}
contentFit='cover'
/>
{/*
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
* faded) above the timestamp (small, bold). Sits on top of the
* trickplay frame inside the same overflow:hidden container so it
* always stays within the bubble bounds.
*/}
<View
pointerEvents='none'
style={{
position: "absolute",
left: 4,
bottom: 3,
alignItems: "flex-start",
paddingHorizontal: 3,
paddingVertical: 1,
borderRadius: 3,
backgroundColor: "rgba(0,0,0,0.55)",
maxWidth: tileWidth - 8,
}}
>
{chapterName ? (
<Text
numberOfLines={1}
style={{
color: "#fff",
fontSize: 7,
opacity: 0.85,
lineHeight: 9,
}}
>
{chapterName}
</Text>
) : null}
<Text
style={{
color: "#fff",
fontSize: 8,
fontWeight: "600",
lineHeight: 10,
}}
>
{timeStr}
</Text>
</View>
</View>
<Text style={{ marginTop: 30, fontSize: 16 }}>
{formatTime(time.hours, time.minutes, time.seconds)}
</Text>
</View>
);
};

View File

@@ -35,6 +35,10 @@ interface UseRemoteControlProps {
onLongSeekStop?: () => void;
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
onVerticalDpad?: () => void;
/** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */
onWillExit?: () => void;
/** Called when the user cancels the exit confirmation Alert */
onCancelExit?: () => void;
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
// These are ignored in the simplified implementation
progress?: SharedValue<number>;
@@ -72,6 +76,8 @@ export function useRemoteControl({
onLongSeekRightStart,
onLongSeekStop,
onVerticalDpad,
onWillExit,
onCancelExit,
}: UseRemoteControlProps) {
// Keep these for backward compatibility with the component
const remoteScrubProgress = useSharedValue<number | null>(null);
@@ -85,13 +91,24 @@ export function useRemoteControl({
const onHideControlsRef = useRef(onHideControls);
const onBackRef = useRef(onBack);
const videoTitleRef = useRef(videoTitle);
const onWillExitRef = useRef(onWillExit);
const onCancelExitRef = useRef(onCancelExit);
useEffect(() => {
showControlsRef.current = showControls;
onHideControlsRef.current = onHideControls;
onBackRef.current = onBack;
videoTitleRef.current = videoTitle;
}, [showControls, onHideControls, onBack, videoTitle]);
onWillExitRef.current = onWillExit;
onCancelExitRef.current = onCancelExit;
}, [
showControls,
onHideControls,
onBack,
videoTitle,
onWillExit,
onCancelExit,
]);
// BackHandler owns player exit: Android TV sends hardware back here, and
// react-native-tvos maps the Apple TV menu button to the same API.
@@ -102,6 +119,9 @@ export function useRemoteControl({
return true;
}
if (onBackRef.current) {
// Signal Controls that exit is imminent (pauses countdown, sets guard)
onWillExitRef.current?.();
// Controls are hidden, so confirm before leaving playback.
Alert.alert(
"Stop Playback",
@@ -109,7 +129,11 @@ export function useRemoteControl({
? `Stop playing "${videoTitleRef.current}"?`
: "Are you sure you want to stop playback?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel",
style: "cancel",
onPress: () => onCancelExitRef.current?.(),
},
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
],
);

View File

@@ -74,6 +74,21 @@ export function useVideoSlider({
[seek, play, progress, isSeeking],
);
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
// during a real slide and would carry stale state on a tap-to-seek.
const seekTo = useCallback(
(value: number) => {
const seekValue = Math.max(0, Math.floor(value));
progress.value = seekValue;
seek(seekValue);
if (isPlaying) {
play();
}
},
[seek, play, progress, isPlaying],
);
const handleSliderChange = useCallback(
debounce((value: number) => {
// Convert ms to ticks for trickplay
@@ -96,5 +111,6 @@ export function useVideoSlider({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
};
}

View File

@@ -28,4 +28,4 @@ type Track = {
localPath?: string;
};
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };

View File

@@ -49,8 +49,8 @@ export const TVGaps = {
* Base padding values in pixels.
*/
export const TVPadding = {
/** Horizontal padding from screen edges */
horizontal: 90,
/** Horizontal padding from screen edges (static — matches native search inset) */
horizontal: 80,
/** Padding to accommodate scale animations (1.05x) */
scale: 20,
@@ -142,7 +142,9 @@ export const useScaledTVSizes = (): ScaledTVSizes => {
large: Math.round(scaleSize(TVGaps.large) * scale),
},
padding: {
horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale),
// Static: matches the native tvOS search bar inset, which is a fixed
// point value and does not change with the typography scale setting.
horizontal: TVPadding.horizontal,
scale: Math.round(scaleSize(TVPadding.scale) * scale),
vertical: Math.round(scaleSize(TVPadding.vertical) * scale),
heroHeight: TVPadding.heroHeight * scale,

View File

@@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 9.1.0"
"version": ">= 9.1.0",
"appVersionSource": "remote"
},
"build": {
"development": {
@@ -52,14 +53,14 @@
},
"production": {
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
@@ -67,7 +68,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
@@ -78,7 +79,7 @@
},
"production_tv": {
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"env": {
"EXPO_TV": "1"
},
@@ -88,7 +89,17 @@
}
},
"submit": {
"production": {},
"production_tv": {}
"production": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
}
}

View File

@@ -13,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
);
};
type ExtraQueryOptions = {
gcTime?: number;
staleTime?: number;
};
export const useItemQuery = (
itemId: string | undefined,
isOffline?: boolean,
fields?: ItemFields[],
excludeFields?: ItemFields[],
queryOptions?: ExtraQueryOptions,
) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -56,5 +62,6 @@ export const useItemQuery = (
refetchOnWindowFocus: true,
refetchOnReconnect: true,
networkMode: "always",
...queryOptions,
});
};

View File

@@ -80,7 +80,7 @@ export const usePlaybackManager = ({
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!item || !item.SeriesId) {
if (!item?.SeriesId) {
return null;
}

View File

@@ -0,0 +1,50 @@
import { useFocusEffect } from "expo-router";
import { useCallback, useRef } from "react";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
// Query keys that depend on the set of library items. Kept in sync with the
// LibraryChanged handler in WebSocketProvider.
const LIBRARY_QUERY_KEYS = [
["home"],
["library-items"],
["nextUp-all"],
["nextUp"],
["resumeItems"],
];
/**
* Fallback refresh for newly added/removed content.
*
* The primary path is the server's `LibraryChanged` WebSocket event (handled in
* WebSocketProvider). This hook is a safety net for cases where the socket was
* down or the change happened while the screen was unfocused: when the screen
* regains focus, it invalidates the library-dependent queries so React Query
* refetches the latest content.
*
* Skips the refresh on the very first focus (initial mount already fetches) and
* throttles to avoid refetch storms when quickly switching tabs.
*/
export function useRefreshLibraryOnFocus(throttleMs = 30_000) {
const queryClient = useNetworkAwareQueryClient();
const hasFocusedOnce = useRef(false);
const lastRefreshRef = useRef(0);
useFocusEffect(
useCallback(() => {
if (!hasFocusedOnce.current) {
hasFocusedOnce.current = true;
return;
}
const now = Date.now();
if (now - lastRefreshRef.current < throttleMs) {
return;
}
lastRefreshRef.current = now;
for (const queryKey of LIBRARY_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey });
}
}, [queryClient, throttleMs]),
);
}

View File

@@ -21,7 +21,7 @@ export const useSessions = ({
const { data, isLoading } = useQuery({
queryKey: ["sessions"],
queryFn: async () => {
if (!api || !user || !user.Policy?.IsAdministrator) {
if (!api || !user?.Policy?.IsAdministrator) {
return [];
}
const response = await getSessionApi(api).getSessions({
@@ -55,7 +55,7 @@ export const useAllSessions = ({
const { data, isLoading } = useQuery({
queryKey: ["allSessions"],
queryFn: async () => {
if (!api || !user || !user.Policy?.IsAdministrator) {
if (!api || !user?.Policy?.IsAdministrator) {
return [];
}
const response = await getSessionApi(api).getSessions({

View File

@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlists"],
});
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId],
});
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlists"],
});
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId],
});

View File

@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
/**
* Attach surface and re-enable video output.
* Based on Findroid's implementation.
* Attach surface and ensure video output is active.
*
* During PiP transitions, the surface is destroyed and recreated by Android.
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
* resumes immediately when the new surface is attached — avoiding the black
* screen that occurs when the VO is fully re-initialized via setOptionString.
*/
fun attachSurface(surface: Surface) {
this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) {
MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", voDriver)
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
// Read back vo to confirm it's still active
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
}
}
/**
* Detach surface and disable video output.
* Based on Findroid's implementation.
* Detach surface without killing the VO pipeline.
*
* The previous approach (vo=null / force-window=no) destroyed the entire video
* output pipeline on every surface transition. During PiP mode, the rapid
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
* did not properly re-initialize rendering into the new PiP surface.
*
* By keeping the VO alive, frames are simply dropped while no surface is
* attached, and rendering resumes immediately when the new surface arrives.
*/
fun detachSurface() {
this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
}
}
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "Surface size updated: ${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize ${width}x${height}")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
}
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
}
}
@@ -684,11 +707,17 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
}
pendingExternalSubtitles = emptyList()
// Set subtitle after external subs are added
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
}
// Apply the initial audio/subtitle selection now that the file's
// tracks are enumerated. Setting sid/aid before `loadfile` does not
// reliably stick for embedded tracks (the selection is silently
// dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection.
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) {
isReadyToSeek = true
mainHandler.post { delegate?.onReadyToSeek() }

View File

@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.os.Build
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.SurfaceHolder
import android.view.SurfaceView
import android.widget.FrameLayout
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* This mirrors the iOS MpvPlayerView implementation.
* Uses TextureView for reliable Picture-in-Picture support.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object {
private const val TAG = "MpvPlayerView"
}
// Event dispatchers
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
private var surfaceView: SurfaceView
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
private var currentUrl: String? = null
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = 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 SurfaceView for video rendering
surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerView)
surfaceTextureListener = this@MpvPlayerView
}
addView(surfaceView)
addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) {
seekBy(seconds)
}
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
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
}
// Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try {
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface ->
renderer?.attachSurface(surface)
pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
}
// MARK: - SurfaceHolder.Callback
override fun surfaceCreated(holder: SurfaceHolder) {
Log.i(TAG, "Surface created")
// MARK: - TextureView.SurfaceTextureListener
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true
if (rendererStarted) {
renderer?.attachSurface(holder.surface)
renderer?.attachSurface(surface)
} else {
// Renderer not started yet - store surface to attach after start
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
pendingSurface = surface
}
// If we have a pending load, execute it now
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pendingConfig = null
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}")
// Update MPV with the new surface size (Findroid approach)
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.i(TAG, "Surface destroyed")
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
loadVideoInternal(config)
}
private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url
renderer?.load(
url = config.url,
headers = config.headers,
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId
)
if (config.autoplay) {
play()
}
onLoad(mapOf("url" to config.url))
}
// Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers))
}
// MARK: - Playback Controls
fun play() {
intendedPlayState = true
renderer?.play()
pipController?.setPlaybackRate(1.0)
}
fun pause() {
intendedPlayState = false
renderer?.pause()
pipController?.setPlaybackRate(0.0)
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
fun seekBy(offset: Double) {
renderer?.seekBy(offset)
}
fun setSpeed(speed: Double) {
renderer?.setSpeed(speed)
}
fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0
}
fun isPaused(): Boolean {
return renderer?.isPausedState ?: true
}
fun getCurrentPosition(): Double {
return cachedPosition
}
fun getDuration(): Double {
return cachedDuration
}
// MARK: - Picture in Picture
fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called")
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()
}
fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false
}
fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList()
}
fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId)
}
fun disableSubtitles() {
renderer?.disableSubtitles()
}
fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select)
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position)
}
fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale)
}
fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin)
}
fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment)
}
fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment)
}
fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size)
}
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList()
}
fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId)
}
fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0
}
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
// Update PiP progress
if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration)
}
onProgress(mapOf(
"position" to position,
"duration" to duration,
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"cacheSeconds" to cacheSeconds
))
}
override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf(
"isPaused" to isPaused,
"isPlaying" to !isPaused
))
}
override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf(
"isLoading" to isLoading
))
}
override fun onReadyToSeek() {
onPlaybackStateChange(mapOf(
"isReadyToSeek" to true
))
}
override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>())
}
override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height)
}
override fun onError(message: String) {
onError(mapOf("error" to message))
}
// MARK: - Cleanup
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
surfaceView.holder.removeCallback(this)
surfaceTexture = null
surfaceReady = false
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cleanup()

View File

@@ -1,51 +1,62 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object {
private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
}
interface Delegate {
fun onPlay()
fun onPause()
fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
}
var delegate: Delegate? = null
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0
private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null
/**
* Check if Picture-in-Picture is supported on this device
*/
// PiP state tracking
private var isInPiPMode: Boolean = false
private var pipEntryNotified: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private var pipBroadcastReceiver: BroadcastReceiver? = null
fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
false
}
}
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
return false
}
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity found")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val activity = getActivity() ?: run {
Log.e(TAG, "Cannot start PiP: no activity")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
val result = activity.enterPictureInPictureMode(params)
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
registerLifecycleCallbacks()
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
}
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user
// or by finishing the activity. We can request to move task to back.
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false)
}
}
}
/**
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun isCurrentlyInPiP(): Boolean = isInPiPMode
fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position
currentDuration = duration
}
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) {
playbackRate = rate
// Update PiP params to reflect play/pause state
if (rate > 0) {
registerLifecycleCallbacks()
}
// Update PiP params so autoEnterEnabled and action icons track play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
if (activity != null) {
try {
activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) {
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) {
videoWidth = width
videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded()
}
}
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) {
playerView = view
}
private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView
val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
// Video aspect ratio with 2.39:1 clamping
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
}
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
} else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
}
} else {
null
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) }
// On Android 12+, enable auto-enter (like Findroid)
ensurePiPReceiverRegistered()
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true)
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
}
return builder.build()
}
private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) {
return ctx
}
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
/**
* Handle PiP action (called from activity when user taps PiP controls)
*/
fun handlePiPAction(action: String) {
when (action) {
"play" -> delegate?.onPlay()
"pause" -> delegate?.onPause()
"skip_forward" -> delegate?.onSeekBy(10.0)
"skip_backward" -> delegate?.onSeekBy(-10.0)
// MARK: - Lifecycle-based PiP Detection
private fun registerLifecycleCallbacks() {
if (lifecycleRegistered) return
val app = context.applicationContext as? Application ?: run {
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
startFallbackPolling()
return
}
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
if (!isInPiPMode) return
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}
override fun onActivityPaused(activity: Activity) {
// Proactively hide controls when user leaves while playing,
// before the PiP window captures the UI. onActivityStopped
// will restore if PiP didn't actually enter.
if (playbackRate > 0 && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
}
}
override fun onActivityStopped(activity: Activity) {
pipHandler.postDelayed({
val inPip = activity.isInPictureInPictureMode
if (inPip && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
return@postDelayed
}
if (!isInPiPMode) return@postDelayed
if (inPip) return@postDelayed
// Not in PiP after 1s — check again to avoid false positive during transition
pipHandler.postDelayed({
if (!isInPiPMode) return@postDelayed
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}, 1500)
}, 1000)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
isInPiPMode = false
}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
if (!lifecycleRegistered) return
lifecycleCallbacks?.let {
(context.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(it)
}
lifecycleCallbacks = null
lifecycleRegistered = false
pipHandler.removeCallbacksAndMessages(null)
unregisterPiPBroadcastReceiver()
}
private fun startFallbackPolling() {
var falseReadCount = 0
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed(object : Runnable {
override fun run() {
if (!isInPiPMode) return
var ctx = context
var activity: Activity? = null
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { activity = ctx; break }
ctx = ctx.baseContext
}
val stillInPip = activity?.isInPictureInPictureMode == true
if (!stillInPip) {
falseReadCount++
if (falseReadCount >= 3) {
isInPiPMode = false
delegate?.onPictureInPictureModeChanged(false)
return
}
pipHandler.postDelayed(this, 500)
return
}
falseReadCount = 0
pipHandler.postDelayed(this, 1000)
}
}, 3000)
}
// MARK: - PiP Remote Actions
private fun ensurePiPReceiverRegistered() {
if (pipBroadcastReceiver != null) return
pipBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_PLAY_PAUSE -> {
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
}
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PIP_PLAY_PAUSE)
addAction(ACTION_PIP_SKIP_FORWARD)
addAction(ACTION_PIP_SKIP_BACKWARD)
}
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
}
private fun unregisterPiPBroadcastReceiver() {
pipBroadcastReceiver?.let {
try {
context.applicationContext.unregisterReceiver(it)
} catch (_: Exception) {}
}
pipBroadcastReceiver = null
}
private fun buildPiPActions(): List<RemoteAction> {
val isPlaying = playbackRate > 0
return listOf(
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
"Rewind", "Skip backward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
),
RemoteAction(
Icon.createWithResource(
context,
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
),
if (isPlaying) "Pause" else "Play",
if (isPlaying) "Pause playback" else "Resume playback",
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
),
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
"Fast Forward", "Skip forward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
)
)
}
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
android.app.PendingIntent.FLAG_IMMUTABLE
} else {
0
}
return android.app.PendingIntent.getBroadcast(
context.applicationContext, 0, Intent(action), flags
)
}
}

View File

@@ -485,8 +485,7 @@ final class MPVLayerRenderer {
switch event.event_id {
case MPV_EVENT_FILE_LOADED:
// Add external subtitles now that the file is loaded
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
if hadExternalSubs, let handle = mpv {
if !pendingExternalSubtitles.isEmpty, let handle = mpv {
for (index, subUrl) in pendingExternalSubtitles.enumerated() {
print("🔧 Adding external subtitle [\(index)]: \(subUrl)")
// Use commandSync to ensure subs are added in exact order (not async)
@@ -494,12 +493,20 @@ final class MPVLayerRenderer {
commandSync(handle, ["sub-add", subUrl, "auto"])
}
pendingExternalSubtitles = []
// Set subtitle after external subs are added
if let subId = initialSubtitleId {
setSubtitleTrack(subId)
} else {
disableSubtitles()
}
}
// Apply the initial audio/subtitle selection now that the file's
// tracks are enumerated. Setting sid/aid before `loadfile` does not
// reliably stick for embedded tracks (the selection is silently
// dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection.
if let audioId = initialAudioId, audioId > 0 {
setAudioTrack(audioId)
}
if let subId = initialSubtitleId {
setSubtitleTrack(subId)
} else {
disableSubtitles()
}
if !isReadyToSeek {
isReadyToSeek = true

View File

@@ -1,32 +1,19 @@
Pod::Spec.new do |s|
s.name = 'MpvPlayer'
s.version = '1.0.0'
s.summary = 'MPVKit for Expo'
s.description = 'MPVKit for Expo'
s.author = 'mpvkit'
s.homepage = 'https://github.com/mpvkit/MPVKit'
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.name = 'MpvPlayer'
s.version = '1.0.0'
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
s.author = 'Streamyfin'
s.homepage = 'https://github.com/streamyfin/streamyfin'
s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit-GPL'
s.dependency 'MPVKit'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'VALID_ARCHS' => 'arm64',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -150,6 +150,16 @@ final class PiPController: NSObject {
CMTimebaseSetRate(tb, rate: Float64(rate))
}
}
deinit {
if let tb = timebase {
CMTimebaseSetRate(tb, rate: 0)
}
sampleBufferDisplayLayer?.controlTimebase = nil
timebase = nil
pipController?.delegate = nil
pipController = nil
}
}
// MARK: - AVPictureInPictureControllerDelegate

View File

@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>;
export type OnPictureInPictureChangePayload = {
isActive: boolean;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload;
}) => void;
};
export interface MpvPlayerViewRef {

View File

@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("MpvPlayer");
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function MpvPlayerView(props, ref) {
const nativeRef = useRef<any>(null);
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
return await nativeRef.current?.getDuration();
},
startPictureInPicture: async () => {
console.log(PIP_LOG, "startPictureInPicture → native");
await nativeRef.current?.startPictureInPicture();
console.log(PIP_LOG, "startPictureInPicture ← native returned");
},
stopPictureInPicture: async () => {
console.log(PIP_LOG, "stopPictureInPicture → native");
await nativeRef.current?.stopPictureInPicture();
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
},
isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported();
const result = await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
},
isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive();
const result = await nativeRef.current?.isPictureInPictureActive();
console.log(PIP_LOG, "isPictureInPictureActive =", result);
return result;
},
getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks();

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["TvSearchModule"]
}
}

View File

@@ -0,0 +1,2 @@
export { default as TvSearchView } from "./src/TvSearchView";
export * from "./src/TvSearchView.types";

View File

@@ -0,0 +1,22 @@
Pod::Spec.new do |s|
s.name = 'TvSearch'
s.version = '1.0.0'
s.summary = 'Native tvOS search field with text change events'
s.description = 'Hosts SwiftUI .searchable inside a UIHostingController so React Native can render its own results grid while using the native tvOS search bar and grid keyboard.'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = {
:tvos => '15.1'
}
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift}"
end

View File

@@ -0,0 +1,15 @@
import ExpoModulesCore
public class TvSearchModule: Module {
public func definition() -> ModuleDefinition {
Name("TvSearchModule")
View(TvSearchView.self) {
Events("onChangeText")
Prop("placeholder") { (view: TvSearchView, value: String?) in
view.setPlaceholder(value ?? "")
}
}
}
}

View File

@@ -0,0 +1,206 @@
import ExpoModulesCore
import SwiftUI
// React Native tvOS notification names for controlling gesture handler behavior.
// These match the constants in RCTTVRemoteHandler.h and are what make keyboard
// input actually reach the native search field on tvOS.
private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name(
"RCTTVDisableGestureHandlersCancelTouchesNotification")
private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name(
"RCTTVEnableGestureHandlersCancelTouchesNotification")
#if os(tvOS)
/// Holds the search state. ObservableObject so we can update placeholder/text
/// without recreating the SwiftUI hierarchy.
class TvSearchViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var placeholder: String = "Search..."
@Published var accentColor: Color = .white
var onSearch: ((String) -> Void)?
}
/// SwiftUI content hosting `.searchable`. This mirrors expo-tvos-search's
/// structure `.searchable` attached inside a `NavigationView` (REQUIRED:
/// `.searchable` only renders a search bar in a navigation context) but with
/// the results grid REMOVED. The body is just transparent filler so the search
/// field + native grid keyboard render; results are drawn by React Native
/// below this native view instead.
struct TvSearchContentView: View {
@ObservedObject var viewModel: TvSearchViewModel
var body: some View {
NavigationView {
// Transparent filler gives `.searchable` something to attach to and
// lets the native search bar/keyboard own the space.
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
.onChange(of: viewModel.searchText) { newValue in
viewModel.onSearch?(newValue)
}
}
.tint(viewModel.accentColor)
}
}
class TvSearchView: ExpoView {
private var hostingController: UIHostingController<TvSearchContentView>?
private let viewModel = TvSearchViewModel()
private var gestureHandlersDisabled = false
private var disabledGestureRecognizers: [UIGestureRecognizer] = []
let onChangeText = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
deinit {
NotificationCenter.default.removeObserver(self)
hostingController?.willMove(toParent: nil)
hostingController?.removeFromParent()
#if !targetEnvironment(simulator)
enableParentGestureRecognizers()
#endif
if gestureHandlersDisabled {
NotificationCenter.default.post(
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
}
}
func setPlaceholder(_ value: String) {
viewModel.placeholder = value
}
private func setupView() {
viewModel.onSearch = { [weak self] query in
self?.onChangeText(["text": query])
}
let controller = UIHostingController(rootView: TvSearchContentView(viewModel: viewModel))
controller.view.backgroundColor = .clear
hostingController = controller
addSubview(controller.view)
controller.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: topAnchor),
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if let parentVC = parentViewController() {
parentVC.addChild(controller)
controller.didMove(toParent: parentVC)
}
// Detect when the search keyboard becomes active so we can release RN's
// remote gesture handling (otherwise keystrokes never reach the field).
NotificationCenter.default.addObserver(
self, selector: #selector(handleTextFieldDidBeginEditing),
name: UITextField.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(handleTextFieldDidEndEditing),
name: UITextField.textDidEndEditingNotification, object: nil)
}
// MARK: - View controller containment
/// SwiftUI needs proper appearance lifecycle events for `.searchable` to
/// register with tvOS's focus system, so we manage child VC containment as
/// the view enters/leaves the window.
override func didMoveToWindow() {
super.didMoveToWindow()
guard let controller = hostingController else { return }
if window != nil {
if controller.parent == nil, let parentVC = parentViewController() {
parentVC.addChild(controller)
controller.didMove(toParent: parentVC)
}
} else {
controller.willMove(toParent: nil)
controller.removeFromParent()
}
}
private func parentViewController() -> UIViewController? {
var responder: UIResponder? = self
while let next = responder?.next {
if let vc = next as? UIViewController { return vc }
responder = next
}
return nil
}
// MARK: - Keyboard / gesture handling
@objc private func handleTextFieldDidBeginEditing(_ notification: Notification) {
guard let textField = notification.object as? UITextField,
let hostingView = hostingController?.view,
textField.isDescendant(of: hostingView)
else { return }
guard !gestureHandlersDisabled else { return }
gestureHandlersDisabled = true
NotificationCenter.default.post(
name: RCTTVDisableGestureHandlersCancelTouchesNotification, object: nil)
#if !targetEnvironment(simulator)
disableParentGestureRecognizers()
#endif
}
@objc private func handleTextFieldDidEndEditing(_ notification: Notification) {
guard let textField = notification.object as? UITextField,
let hostingView = hostingController?.view,
textField.isDescendant(of: hostingView)
else { return }
guard gestureHandlersDisabled else { return }
gestureHandlersDisabled = false
#if !targetEnvironment(simulator)
enableParentGestureRecognizers()
#endif
NotificationCenter.default.post(
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
}
private func disableParentGestureRecognizers() {
disabledGestureRecognizers.removeAll()
var currentView: UIView? = superview
while let view = currentView {
for recognizer in view.gestureRecognizers ?? [] {
let isTapOrPress =
recognizer is UITapGestureRecognizer || recognizer is UILongPressGestureRecognizer
if isTapOrPress && recognizer.isEnabled {
recognizer.isEnabled = false
disabledGestureRecognizers.append(recognizer)
}
}
currentView = view.superview
}
}
private func enableParentGestureRecognizers() {
for recognizer in disabledGestureRecognizers {
recognizer.isEnabled = true
}
disabledGestureRecognizers.removeAll()
}
}
#else
// Fallback for non-tvOS platforms (iOS).
class TvSearchView: ExpoView {
let onChangeText = EventDispatcher()
func setPlaceholder(_ value: String) {}
}
#endif

View File

@@ -0,0 +1,10 @@
{
"name": "tv-search",
"version": "0.1.0",
"description": "Native tvOS search field (SwiftUI .searchable) emitting typed text to React Native",
"main": "index.ts",
"platforms": [
"apple"
],
"devDependencies": {}
}

View File

@@ -0,0 +1,22 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types";
const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule");
/**
* Forwards its ref to the underlying native view so it can be used as a
* `TVFocusGuideView` `destinations` target for routing focus into the native
* search bar.
*/
const TvSearchView = React.forwardRef<View, TvSearchViewProps>((props, ref) => {
return <NativeView ref={ref} {...props} />;
});
TvSearchView.displayName = "TvSearchView";
export default TvSearchView;

View File

@@ -0,0 +1,12 @@
import type { ViewProps } from "react-native";
export interface TvSearchTextChangeEvent {
nativeEvent: { text: string };
}
export interface TvSearchViewProps extends ViewProps {
/** Placeholder shown in the native search bar. */
placeholder?: string;
/** Fired as the user types in the native search bar. */
onChangeText?: (event: TvSearchTextChangeEvent) => void;
}

View File

@@ -28,61 +28,59 @@
"dependencies": {
"@bottom-tabs/react-navigation": "1.2.0",
"@douglowder/expo-av-route-picker-view": "^0.0.5",
"@expo/metro-runtime": "~55.0.11",
"@expo/metro-runtime": "~56.0.13",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~55.0.17",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.9",
"@react-navigation/native": "^7.2.5",
"@react-navigation/native-stack": "~7.14.5",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/query-sync-storage-persister": "^5.100.14",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query-persist-client": "^5.100.14",
"axios": "^1.7.9",
"expo": "~55.0.26",
"expo-application": "~55.0.15",
"expo-asset": "~55.0.17",
"expo-audio": "~55.0.0",
"expo-background-task": "~55.0.18",
"expo-blur": "~55.0.14",
"expo-brightness": "~55.0.13",
"expo-build-properties": "~55.0.14",
"expo-camera": "~55.0.19",
"expo-constants": "~55.0.16",
"expo-crypto": "~55.0.15",
"expo-dev-client": "~55.0.35",
"expo-device": "~55.0.17",
"expo-font": "~55.0.8",
"expo-haptics": "~55.0.14",
"expo-image": "~55.0.11",
"expo-linear-gradient": "~55.0.14",
"expo-linking": "~55.0.15",
"expo-localization": "~55.0.15",
"expo-location": "~55.1.10",
"expo-notifications": "~55.0.23",
"expo-router": "~55.0.16",
"expo-screen-orientation": "~55.0.16",
"expo-secure-store": "~55.0.14",
"expo-sharing": "~55.0.20",
"expo-splash-screen": "~55.0.21",
"expo-status-bar": "~55.0.6",
"expo-system-ui": "~55.0.18",
"expo-task-manager": "~55.0.16",
"expo-web-browser": "~55.0.16",
"i18next": "^25.0.0",
"jotai": "2.16.2",
"lodash": "4.17.23",
"expo": "~56.0.6",
"expo-application": "~56.0.3",
"expo-asset": "~56.0.15",
"expo-audio": "~56.0.11",
"expo-background-task": "~56.0.15",
"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-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16",
"expo-device": "~56.0.4",
"expo-font": "~56.0.5",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.9",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
"expo-localization": "~56.0.6",
"expo-location": "~56.0.14",
"expo-notifications": "~56.0.14",
"expo-router": "~56.2.7",
"expo-screen-orientation": "~56.0.5",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.14",
"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-web-browser": "~56.0.5",
"i18next": "^26.3.0",
"jotai": "2.20.0",
"lodash": "4.18.1",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-i18next": "16.5.3",
"react-native": "npm:react-native-tvos@0.83.6-0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-i18next": "17.0.8",
"react-native": "npm:react-native-tvos@0.85.3-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.2.0",
"react-native-circular-progress": "^1.4.1",
@@ -91,7 +89,7 @@
"react-native-device-info": "^15.0.0",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.30.0",
"react-native-gesture-handler": "~2.31.1",
"react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0",
@@ -99,13 +97,13 @@
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.0",
"react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.2.1",
"react-native-reanimated": "4.3.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0",
"react-native-svg": "15.15.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
@@ -113,19 +111,19 @@
"react-native-uuid": "^2.0.3",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.7.4",
"react-native-worklets": "0.8.3",
"sonner-native": "0.21.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"zod": "4.1.13"
"zod": "4.4.3"
},
"devDependencies": {
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@babel/core": "7.29.7",
"@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",
"@types/lodash": "4.17.24",
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
@@ -148,6 +146,7 @@
},
"install": {
"exclude": [
"react-native",
"react-native-screens"
]
}
@@ -163,11 +162,5 @@
},
"trustedDependencies": [
"unrs-resolver"
],
"patchedDependencies": {
"react-native-screens@4.18.0": "bun-patches/react-native-screens@4.18.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"@react-native/codegen@0.83.6": "bun-patches/@react-native%2Fcodegen@0.83.6.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch"
}
]
}

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
}
#endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown

View File

@@ -0,0 +1,28 @@
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType);
};
- var closestParentReactContentView: RCTRootContentView? {
- let targetType = RCTRootContentView.self;
-
- if let match = self.recursivelyFindParentView(whereType: targetType) {
- return match;
- };
-
- guard let rootView = self.rootViewForCurrentWindow else { return nil };
- return rootView.recursivelyFindSubview(whereType: targetType);
+ // PATCH (streamyfin): RCTRootContentView is a legacy paper class that the prebuilt
+ // new-architecture React (RN 0.85) does not export, so any reference to it fails to
+ // link (Undefined symbols: _OBJC_CLASS_$_RCTRootContentView). The app runs the new
+ // architecture, where this content-view lookup is unused; short-circuit to nil.
+ // Return type widened to RCTView? so the caller's `.reactTouchHandlers` (an RCTView
+ // extension) still resolves.
+ var closestParentReactContentView: RCTView? {
+ return nil;
};
var reactTouchHandlers: [RCTTouchHandler]? {

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
--- a/node_modules/react-native-udp/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]

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