chore: expo 55 upgrade (#1594)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
This commit is contained in:
lance chant
2026-05-27 21:38:08 +02:00
committed by GitHub
parent 82eaf62354
commit eb02ac253a
14 changed files with 747 additions and 788 deletions

2
.gitignore vendored
View File

@@ -73,4 +73,4 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
.claude/settings.local.json .claude/

View File

@@ -7,8 +7,6 @@
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -38,7 +36,6 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes",
"versionCode": 92, "versionCode": 92,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",

View File

@@ -362,8 +362,8 @@ function Layout() {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`; url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season // summarized season notification for multiple episodes. Bring them to series season
} else { } else {
const seriesId = data.seriesId; const seriesId = data?.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data?.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`; url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else { } else {

View File

@@ -0,0 +1,29 @@
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,21 +1,26 @@
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift new file mode 100644
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
@@ -8,7 +8,7 @@ diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate self.delegate = delegate
} }
- #if !os(macOS) - #if !os(macOS)
+ #if !os(macOS) && !os(tvOS) + #if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *) @available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none" var placementValue = "none"
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift --- a/ios/TabView/NewTabView.swift
@@ -67,11 +67,11 @@ +++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
} }
func body(content: Content) -> some View { func body(content: Content) -> some View {
- #if os(macOS) - #if os(macOS)
- // tabViewBottomAccessory is not available on macOS - // tabViewBottomAccessory is not available on macOS
@@ -28,8 +33,8 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
content content
.tabViewBottomAccessory { .tabViewBottomAccessory {
renderBottomAccessoryView() renderBottomAccessoryView()
@@ -84,7 +84,7 @@ @@ -95,7 +95,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
@ViewBuilder @ViewBuilder
private func renderBottomAccessoryView() -> some View { private func renderBottomAccessoryView() -> some View {
- #if !os(macOS) - #if !os(macOS)
@@ -37,20 +42,26 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
if let bottomAccessoryView { if let bottomAccessoryView {
if #available(iOS 26.0, *) { if #available(iOS 26.0, *) {
BottomAccessoryRepresentableView(view: bottomAccessoryView) BottomAccessoryRepresentableView(view: bottomAccessoryView)
@@ -94,7 +94,7 @@ @@ -105,7 +105,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
} }
} }
-#if !os(macOS) -#if !os(macOS)
+#if !os(macOS) && !os(tvOS) +#if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *) @available(iOS 26.0, *)
struct BottomAccessoryRepresentableView: PlatformViewRepresentable { struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement @Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -135,3 +135,4 @@ struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift }
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift }
@@ -281,7 +281,7 @@ #endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder @ViewBuilder
func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View { func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View {
- #if compiler(>=6.2) - #if compiler(>=6.2)
@@ -58,13 +69,14 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_
if #available(iOS 26.0, macOS 26.0, *) { if #available(iOS 26.0, macOS 26.0, *) {
if let behavior { if let behavior {
self.tabBarMinimizeBehavior(behavior.convert()) self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift --- a/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ +++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp case onScrollUp
case onScrollDown case onScrollDown
-#if compiler(>=6.2) -#if compiler(>=6.2)
+#if compiler(>=6.2) && !os(tvOS) +#if compiler(>=6.2) && !os(tvOS)
@available(iOS 26.0, macOS 26.0, *) @available(iOS 26.0, macOS 26.0, *)

View File

@@ -1,5 +1,14 @@
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 diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e4e1b8a08 100644 index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
--- a/ios/RNSScreenStack.mm --- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm +++ b/ios/RNSScreenStack.mm
@@ -34,6 +34,11 @@ @@ -34,6 +34,11 @@
@@ -27,11 +36,10 @@ index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e
@interface RNSScreenStackView () < @interface RNSScreenStackView () <
UINavigationControllerDelegate, UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate, UIAdaptivePresentationControllerDelegate,
@@ -61,6 +72,57 @@ @@ -62,6 +73,57 @@ namespace react = facebook::react;
@end
@implementation RNSNavigationController @implementation RNSNavigationController
+
+#if TARGET_OS_TV +#if TARGET_OS_TV
+- (void)viewDidLoad +- (void)viewDidLoad
+{ +{
@@ -82,6 +90,102 @@ index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e
+ target:nil]; + target:nil];
+} +}
+#endif // TARGET_OS_TV +#endif // TARGET_OS_TV
+
#if !TARGET_OS_TV #if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle - (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

912
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,21 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; 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 { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -201,6 +214,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -232,10 +263,24 @@ const PlatformDropdownComponent = ({
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Host style={expoUIConfig?.hostStyle}> <View>
<ContextMenu> {/* Hidden measurer: lays the trigger out normally to capture its
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger> intrinsic size, which we then pin onto the Host below. */}
<ContextMenu.Items> <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,
]}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => { {groups.flatMap((group, groupIndex) => {
// Check if this group has radio options // Check if this group has radio options
const radioOptions = group.options.filter( const radioOptions = group.options.filter(
@@ -254,23 +299,37 @@ const PlatformDropdownComponent = ({
// Otherwise render as individual buttons // Otherwise render as individual buttons
if (radioOptions.length > 0) { if (radioOptions.length > 0) {
if (group.title) { if (group.title) {
// Use Picker for grouped options // 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(
(opt) => opt.selected,
);
items.push( items.push(
<Picker <Picker
key={`picker-${groupIndex}`} key={`picker-${groupIndex}`}
label={group.title} label={group.title}
options={radioOptions.map((opt) => opt.label)} selection={
variant='menu' selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
selectedIndex={radioOptions.findIndex( }
(opt) => opt.selected, onSelectionChange={(index) => {
)} const selectedOption = radioOptions[index as number];
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress(); selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value); onOptionSelect?.(selectedOption?.value);
}} }}
/>, >
{radioOptions.map((opt, optionIndex) => (
<SwiftUIText
key={`radio-${groupIndex}-${optionIndex}`}
modifiers={[tag(optionIndex)]}
>
{opt.label}
</SwiftUIText>
))}
</Picker>,
); );
} else { } else {
// Render radio options as direct buttons // Render radio options as direct buttons
@@ -278,17 +337,18 @@ const PlatformDropdownComponent = ({
items.push( items.push(
<Button <Button
key={`radio-${groupIndex}-${optionIndex}`} key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={ systemImage={
option.selected ? "checkmark.circle.fill" : "circle" option.selected ? "checkmark.circle.fill" : "circle"
} }
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => { onPress={() => {
option.onPress(); option.onPress();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
disabled={option.disabled} />,
>
{option.label}
</Button>,
); );
}); });
} }
@@ -299,17 +359,16 @@ const PlatformDropdownComponent = ({
items.push( items.push(
<Button <Button
key={`toggle-${groupIndex}-${optionIndex}`} key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={ systemImage={
option.value ? "checkmark.circle.fill" : "circle" option.value ? "checkmark.circle.fill" : "circle"
} }
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onToggle(); option.onToggle();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
disabled={option.disabled} />,
>
{option.label}
</Button>,
); );
}); });
@@ -318,21 +377,20 @@ const PlatformDropdownComponent = ({
items.push( items.push(
<Button <Button
key={`action-${groupIndex}-${optionIndex}`} key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onPress(); option.onPress();
}} }}
disabled={option.disabled} />,
>
{option.label}
</Button>,
); );
}); });
return items; return items;
})} })}
</ContextMenu.Items> </Menu>
</ContextMenu> </Host>
</Host> </View>
); );
} }

View File

@@ -1,4 +1,11 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; 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 { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
@@ -43,38 +50,37 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<ContextMenu> <ContextMenu>
<ContextMenu.Trigger> <ContextMenu.Trigger>
<Button <Button
variant='glass' modifiers={[buttonStyle("glass")]}
modifiers={[]}
systemImage='line.3.horizontal.decrease.circle' systemImage='line.3.horizontal.decrease.circle'
></Button> ></Button>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Items> <ContextMenu.Items>
<Picker <Picker
label={t("library.filters.sort_by")} label={t("library.filters.sort_by")}
options={sortOptions.map((item) => selection={jellyseerrOrderBy as unknown as string}
t(`home.settings.plugins.jellyseerr.order_by.${item}`), onSelectionChange={(value) => {
)} setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}} }}
/> >
{sortOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
</SwiftUIText>
))}
</Picker>
<Picker <Picker
label={t("library.filters.sort_order")} label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))} selection={jellyseerrSortOrder}
variant='menu' onSelectionChange={(value) => {
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} setJellyseerrSortOrder(value as "asc" | "desc");
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
}} }}
/> >
{orderOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`library.filters.${item}`)}
</SwiftUIText>
))}
</Picker>
</ContextMenu.Items> </ContextMenu.Items>
</ContextMenu> </ContextMenu>
</Host> </Host>

View File

@@ -1,5 +1,7 @@
import { Button, Host } from "@expo/ui/swift-ui"; import { Button, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Tag } from "@/components/GenreTags"; import { Tag } from "@/components/GenreTags";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -28,10 +30,14 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
}} }}
> >
<Button <Button
variant={searchType === "Library" ? "glassProminent" : "glass"} modifiers={[
buttonStyle(
searchType === "Library" ? "glassProminent" : "glass",
),
]}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
> >
{t("search.library")} <Text>{t("search.library")}</Text>
</Button> </Button>
</Host> </Host>
<Host <Host
@@ -44,10 +50,14 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
}} }}
> >
<Button <Button
variant={searchType === "Discover" ? "glassProminent" : "glass"} modifiers={[
buttonStyle(
searchType === "Discover" ? "glassProminent" : "glass",
),
]}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
> >
{t("search.discover")} <Text>{t("search.discover")}</Text>
</Button> </Button>
</Host> </Host>
</> </>

View File

@@ -123,7 +123,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
</View> </View>
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && ( {/* Rotate toggle is Android-only: iOS does not reliably rotate the
player back to portrait programmatically. */}
{Platform.OS === "android" && (
<TouchableOpacity <TouchableOpacity
onPress={toggleOrientation} onPress={toggleOrientation}
disabled={isTogglingOrientation} disabled={isTogglingOrientation}

View File

@@ -1,7 +1,10 @@
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Audio as AudioType } from "expo-av"; import {
import { Audio } from "expo-av"; type AudioPlayer,
createAudioPlayer,
setAudioModeAsync,
} from "expo-audio";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
@@ -18,7 +21,7 @@ const FADE_STEP_MS = 50;
* Returns a cleanup function that cancels the fade. * Returns a cleanup function that cancels the fade.
*/ */
function fadeVolume( function fadeVolume(
sound: AudioType.Sound, player: AudioPlayer,
from: number, from: number,
to: number, to: number,
duration: number, duration: number,
@@ -38,23 +41,19 @@ function fadeVolume(
const tick = () => { const tick = () => {
if (cancelled || step >= steps) { if (cancelled || step >= steps) {
if (!cancelled) { if (!cancelled) {
sound.setVolumeAsync(to).catch(() => {}); player.volume = to;
} }
resolve(); resolve();
return; return;
} }
step++; step++;
current += delta; current += delta;
sound player.volume = Math.max(0, Math.min(1, current));
.setVolumeAsync(Math.max(0, Math.min(1, current))) if (!cancelled) {
.catch(() => {}) setTimeout(tick, FADE_STEP_MS);
.then(() => { } else {
if (!cancelled) { resolve();
setTimeout(tick, FADE_STEP_MS); }
} else {
resolve();
}
});
}; };
tick(); tick();
@@ -64,41 +63,35 @@ function fadeVolume(
} }
// --- Module-level singleton state --- // --- Module-level singleton state ---
let sharedSound: AudioType.Sound | null = null; let sharedPlayer: AudioPlayer | null = null;
let currentSongId: string | null = null; let currentSongId: string | null = null;
let ownerCount = 0; let ownerCount = 0;
let activeFade: { cancel: () => void } | null = null; let activeFade: { cancel: () => void } | null = null;
let cleanupPromise: Promise<void> | null = null; let cleanupPromise: Promise<void> | null = null;
/** Fade out, stop, and unload the shared sound. */ /** Fade out, stop, and release the shared player. */
async function teardownSharedSound(): Promise<void> { async function teardownSharedPlayer(): Promise<void> {
const sound = sharedSound; const player = sharedPlayer;
if (!sound) return; if (!player) return;
activeFade?.cancel(); activeFade?.cancel();
activeFade = null; activeFade = null;
try { try {
const status = await sound.getStatusAsync(); if (player.isLoaded) {
if (status.isLoaded) { const currentVolume = player.volume ?? TARGET_VOLUME;
const currentVolume = status.volume ?? TARGET_VOLUME; const fade = fadeVolume(player, currentVolume, 0, FADE_OUT_DURATION);
const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION);
activeFade = fade; activeFade = fade;
await fade.promise; await fade.promise;
activeFade = null; activeFade = null;
await sound.stopAsync(); player.pause();
await sound.unloadAsync();
} }
} catch { } catch {
try { // ignore
await sound.unloadAsync();
} catch {
// ignore
}
} }
if (sharedSound === sound) { if (sharedPlayer === player) {
sharedSound = null; sharedPlayer = null;
currentSongId = null; currentSongId = null;
} }
} }
@@ -106,7 +99,7 @@ async function teardownSharedSound(): Promise<void> {
/** Begin cleanup idempotently; returns the shared promise. */ /** Begin cleanup idempotently; returns the shared promise. */
function beginCleanup(): Promise<void> { function beginCleanup(): Promise<void> {
if (!cleanupPromise) { if (!cleanupPromise) {
cleanupPromise = teardownSharedSound().finally(() => { cleanupPromise = teardownSharedPlayer().finally(() => {
cleanupPromise = null; cleanupPromise = null;
}); });
} }
@@ -154,12 +147,12 @@ export function useTVThemeMusic(itemId: string | undefined) {
const startPlayback = async () => { const startPlayback = async () => {
// If the same song is already playing, keep it going // If the same song is already playing, keep it going
if (currentSongId === songId && sharedSound) { if (currentSongId === songId && sharedPlayer) {
return; return;
} }
// If a different song is playing (or cleanup is in progress), tear it down first // If a different song is playing (or cleanup is in progress), tear it down first
if (sharedSound || cleanupPromise) { if (sharedPlayer || cleanupPromise) {
activeFade?.cancel(); activeFade?.cancel();
activeFade = null; activeFade = null;
await beginCleanup(); await beginCleanup();
@@ -167,14 +160,14 @@ export function useTVThemeMusic(itemId: string | undefined) {
if (!mounted) return; if (!mounted) return;
const sound = new Audio.Sound(); const player = createAudioPlayer(null);
sharedSound = sound; sharedPlayer = player;
currentSongId = songId; currentSongId = songId;
try { try {
await Audio.setAudioModeAsync({ await setAudioModeAsync({
playsInSilentModeIOS: true, playsInSilentMode: true,
staysActiveInBackground: false, shouldPlayInBackground: false,
}); });
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -190,19 +183,19 @@ export function useTVThemeMusic(itemId: string | undefined) {
EnableRemoteMedia: "false", EnableRemoteMedia: "false",
}); });
const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`;
await sound.loadAsync({ uri: url }); player.replace({ uri: url });
if (!mounted || sharedSound !== sound) { if (!mounted || sharedPlayer !== player) {
await sound.unloadAsync(); player.pause();
return; return;
} }
await sound.setIsLoopingAsync(true); player.loop = true;
await sound.setVolumeAsync(0); player.volume = 0;
await sound.playAsync(); player.play();
if (mounted && sharedSound === sound) { if (mounted && sharedPlayer === player) {
const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION); const fade = fadeVolume(player, 0, TARGET_VOLUME, FADE_IN_DURATION);
activeFade = fade; activeFade = fade;
await fade.promise; await fade.promise;
activeFade = null; activeFade = null;

View File

@@ -25,71 +25,72 @@
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "1.1.0", "@bottom-tabs/react-navigation": "1.2.0",
"@douglowder/expo-av-route-picker-view": "^0.0.5", "@douglowder/expo-av-route-picker-view": "^0.0.5",
"@expo/metro-runtime": "~6.1.1", "@expo/metro-runtime": "~55.0.11",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "0.2.0-beta.9", "@expo/ui": "~55.0.17",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "11.5.2",
"@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/material-top-tabs": "7.4.9",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.2.5",
"@react-navigation/native-stack": "~7.14.5",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1", "@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "^5.90.18", "@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "~54.0.31", "expo": "~55.0.26",
"expo-application": "~7.0.8", "expo-application": "~55.0.15",
"expo-asset": "~12.0.12", "expo-asset": "~55.0.17",
"expo-av": "^16.0.8", "expo-audio": "~55.0.0",
"expo-background-task": "~1.0.10", "expo-background-task": "~55.0.18",
"expo-blur": "~15.0.8", "expo-blur": "~55.0.14",
"expo-brightness": "~14.0.8", "expo-brightness": "~55.0.13",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~55.0.14",
"expo-camera": "^55.0.18", "expo-camera": "~55.0.19",
"expo-constants": "18.0.13", "expo-constants": "~55.0.16",
"expo-crypto": "^15.0.8", "expo-crypto": "~55.0.15",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~55.0.35",
"expo-device": "~8.0.10", "expo-device": "~55.0.17",
"expo-font": "~14.0.10", "expo-font": "~55.0.8",
"expo-haptics": "~15.0.8", "expo-haptics": "~55.0.14",
"expo-image": "~3.0.11", "expo-image": "~55.0.11",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~55.0.14",
"expo-linking": "~8.0.11", "expo-linking": "~55.0.15",
"expo-localization": "~17.0.8", "expo-localization": "~55.0.15",
"expo-location": "^19.0.8", "expo-location": "~55.1.10",
"expo-notifications": "~0.32.16", "expo-notifications": "~55.0.23",
"expo-router": "~6.0.21", "expo-router": "~55.0.16",
"expo-screen-orientation": "~9.0.8", "expo-screen-orientation": "~55.0.16",
"expo-secure-store": "^15.0.8", "expo-secure-store": "~55.0.14",
"expo-sharing": "~14.0.8", "expo-sharing": "~55.0.20",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~55.0.21",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~55.0.6",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~55.0.18",
"expo-task-manager": "14.0.9", "expo-task-manager": "~55.0.16",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~55.0.16",
"i18next": "^25.0.0", "i18next": "^25.0.0",
"jotai": "2.16.2", "jotai": "2.16.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.1.0", "react": "19.2.0",
"react-dom": "19.1.0", "react-dom": "19.2.0",
"react-i18next": "16.5.3", "react-i18next": "16.5.3",
"react-native": "npm:react-native-tvos@0.81.5-2", "react-native": "npm:react-native-tvos@0.83.6-0",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.2.0",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2", "react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0", "react-native-device-info": "^15.0.0",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0", "react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "2.28.0", "react-native-gesture-handler": "~2.30.0",
"react-native-glass-effect-view": "^1.0.0", "react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1", "react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
@@ -97,13 +98,13 @@
"react-native-ios-utilities": "5.2.0", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1", "react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.1", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "8.0.0",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "4.2.1",
"react-native-reanimated-carousel": "4.0.3", "react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0", "react-native-screens": "~4.18.0",
"react-native-svg": "15.12.1", "react-native-svg": "15.15.3",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
@@ -111,7 +112,7 @@
"react-native-uuid": "^2.0.3", "react-native-uuid": "^2.0.3",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.7.4",
"sonner-native": "0.21.2", "sonner-native": "0.21.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
@@ -124,7 +125,7 @@
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.23",
"@types/react": "19.1.17", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.17.14", "expo-doctor": "1.17.14",
@@ -164,9 +165,8 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"react-native-screens@4.18.0": "bun-patches/react-native-screens@4.18.0.patch", "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-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",
"resolutions": { "react-native-bottom-tabs@1.2.0": "patches/react-native-bottom-tabs@1.2.0.patch"
"expo-constants": "18.0.13"
} }
} }

View File

@@ -24,6 +24,34 @@ function buildPatch() {
" t.build_configurations.each do |cfg|", " t.build_configurations.each do |cfg|",
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'", " cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"", " cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
" cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)",
" content = File.read(filepath)",
" if content =~ /thirdPartyComponents = @\\{([\\s\\S]*?)\\};/",
" entries = $1",
' new_code = "NSMutableDictionary *dict = [NSMutableDictionary dictionary];\\n"',
' new_code += " Class cls;\\n"',
" entries.each_line do |line|",
" line = line.strip",
" next if line.empty?",
' if line =~ /@\\"(.*?)\\":\\s*NSClassFromString\\(@\\"(.*?)\\"\\),?(.*)/',
" key = $1",
" val = $2",
" comment = $3",
' new_code += " cls = NSClassFromString(@\\"#{val}\\"); if (cls) dict[@\\"#{key}\\"] = cls;#{comment}\\n"',
" else",
' new_code += " // #{line}\\n"',
" end",
" end",
' new_code += " thirdPartyComponents = dict;"',
" content = content.sub(/thirdPartyComponents = @\\{[\\s\\S]*?\\};/, new_code)",
" File.write(filepath, content)",
' puts "✅ Patched RCTThirdPartyComponentsProvider.mm for safety"',
" end", " end",
" end", " end",
PATCH_END, PATCH_END,