From 4ad103acb6cc54a1210561904f08fd5977413698 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:04:09 +0100 Subject: [PATCH 001/309] fix: conditionals for tv to build / run --- components/GenreTags.tsx | 2 +- components/common/Input.tsx | 139 ++++++++++++++---- components/music/MiniPlayerBar.tsx | 2 +- hooks/useWifiSSID.ts | 47 ++++-- index.js | 10 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 2 +- modules/mpv-player/ios/MpvPlayerView.swift | 2 + modules/wifi-ssid/ios/WifiSsidModule.swift | 14 ++ package.json | 2 +- patches/react-native-bottom-tabs+1.1.0.patch | 72 +++++++++ react-native.config.js | 5 + 11 files changed, 249 insertions(+), 48 deletions(-) create mode 100644 patches/react-native-bottom-tabs+1.1.0.patch diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index 030d554cf..540f153c4 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -23,7 +23,7 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( diff --git a/components/common/Input.tsx b/components/common/Input.tsx index 8d7f602f8..948e46853 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -1,50 +1,125 @@ -import React, { useState } from "react"; +import { useRef, useState } from "react"; import { + Animated, + Easing, Platform, + Pressable, TextInput, type TextInputProps, - TouchableOpacity, + View, } from "react-native"; interface InputProps extends TextInputProps { - extraClassName?: string; // new prop for additional classes + extraClassName?: string; } export function Input(props: InputProps) { const { style, extraClassName = "", ...otherProps } = props; - const inputRef = React.useRef(null); + const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; - return Platform.isTV ? ( - inputRef?.current?.focus?.()} - activeOpacity={1} - > - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - {...otherProps} - /> - - ) : ( + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + if (Platform.isTV) { + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + > + + {/* Outer glow when focused */} + {isFocused && ( + + )} + + + {/* Purple accent bar at top when focused */} + {isFocused && ( + + )} + + + + + + ); + } + + // Mobile version unchanged + return ( { ]} > - {Platform.OS === "ios" ? ( + {Platform.OS === "ios" && !Platform.isTV ? ( ({ + ssid: null, + permissionStatus: "unavailable" as PermissionStatus, + requestPermission: async () => false, + isLoading: false, + }), + }; +} + +// Only import Location on non-TV platforms +const Location = Platform.isTV ? null : require("expo-location"); + +function mapLocationStatus(status: number | undefined): PermissionStatus { + if (!Location) return "unavailable"; switch (status) { - case Location.PermissionStatus.GRANTED: + case Location.PermissionStatus?.GRANTED: return "granted"; - case Location.PermissionStatus.DENIED: + case Location.PermissionStatus?.DENIED: return "denied"; default: return "undetermined"; @@ -30,17 +45,24 @@ function mapLocationStatus( export function useWifiSSID(): UseWifiSSIDReturn { const [ssid, setSSID] = useState(null); - const [permissionStatus, setPermissionStatus] = - useState("undetermined"); - const [isLoading, setIsLoading] = useState(true); + const [permissionStatus, setPermissionStatus] = useState( + Platform.isTV ? "unavailable" : "undetermined", + ); + const [isLoading, setIsLoading] = useState(!Platform.isTV); const fetchSSID = useCallback(async () => { + if (Platform.isTV) return; const result = await getSSID(); console.log("[WiFi Debug] Native module SSID:", result); setSSID(result); }, []); const requestPermission = useCallback(async (): Promise => { + if (Platform.isTV || !Location) { + setPermissionStatus("unavailable"); + return false; + } + try { const { status } = await Location.requestForegroundPermissionsAsync(); const newStatus = mapLocationStatus(status); @@ -58,6 +80,11 @@ export function useWifiSSID(): UseWifiSSIDReturn { }, [fetchSSID]); useEffect(() => { + if (Platform.isTV || !Location) { + setIsLoading(false); + return; + } + async function initialize() { setIsLoading(true); try { @@ -79,6 +106,8 @@ export function useWifiSSID(): UseWifiSSIDReturn { // Refresh SSID when permission status changes to granted useEffect(() => { + if (Platform.isTV) return; + if (permissionStatus === "granted") { fetchSSID(); diff --git a/index.js b/index.js index dbc9139f3..7a0294a3e 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,10 @@ import "react-native-url-polyfill/auto"; -import TrackPlayer from "react-native-track-player"; -import { PlaybackService } from "./services/PlaybackService"; +import { Platform } from "react-native"; import "expo-router/entry"; -TrackPlayer.registerPlaybackService(() => PlaybackService); +// TrackPlayer is not supported on tvOS +if (!Platform.isTV) { + const TrackPlayer = require("react-native-track-player").default; + const { PlaybackService } = require("./services/PlaybackService"); + TrackPlayer.registerPlaybackService(() => PlaybackService); +} diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index af3467631..ad61bc643 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -219,7 +219,7 @@ final class MPVLayerRenderer { DispatchQueue.main.async { [weak self] in guard let self else { return } - if #available(iOS 18.0, *) { + if #available(iOS 18.0, tvOS 17.0, *) { self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) } else { self.displayLayer.flushAndRemoveImage() diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 608448b8b..71ee21e22 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -72,9 +72,11 @@ class MpvPlayerView: ExpoView { displayLayer.frame = bounds displayLayer.videoGravity = .resizeAspect + #if !os(tvOS) if #available(iOS 17.0, *) { displayLayer.wantsExtendedDynamicRangeContent = true } + #endif displayLayer.backgroundColor = UIColor.black.cgColor videoContainer.layer.addSublayer(displayLayer) diff --git a/modules/wifi-ssid/ios/WifiSsidModule.swift b/modules/wifi-ssid/ios/WifiSsidModule.swift index 0a2a5faa6..5254fcb5d 100644 --- a/modules/wifi-ssid/ios/WifiSsidModule.swift +++ b/modules/wifi-ssid/ios/WifiSsidModule.swift @@ -1,13 +1,19 @@ import ExpoModulesCore +#if !os(tvOS) import NetworkExtension import SystemConfiguration.CaptiveNetwork +#endif public class WifiSsidModule: Module { public func definition() -> ModuleDefinition { Name("WifiSsid") // Get current WiFi SSID using NEHotspotNetwork (iOS 14+) + // Not available on tvOS AsyncFunction("getSSID") { () -> String? in + #if os(tvOS) + return nil + #else return await withCheckedContinuation { continuation in NEHotspotNetwork.fetchCurrent { network in if let ssid = network?.ssid { @@ -21,14 +27,21 @@ public class WifiSsidModule: Module { } } } + #endif } // Synchronous version using only CNCopyCurrentNetworkInfo + // Not available on tvOS Function("getSSIDSync") { () -> String? in + #if os(tvOS) + return nil + #else return self.getSSIDViaCNCopy() + #endif } } + #if !os(tvOS) private func getSSIDViaCNCopy() -> String? { guard let interfaces = CNCopySupportedInterfaces() as? [String] else { print("[WifiSsid] CNCopySupportedInterfaces returned nil") @@ -49,4 +62,5 @@ public class WifiSsidModule: Module { print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo") return nil } + #endif } diff --git a/package.json b/package.json index 2679912cb..d4750406a 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-i18next": "16.5.3", - "react-native": "0.81.5", + "react-native": "npm:react-native-tvos@0.81.5-2", "react-native-awesome-slider": "^2.9.0", "react-native-bottom-tabs": "1.1.0", "react-native-circular-progress": "^1.4.1", diff --git a/patches/react-native-bottom-tabs+1.1.0.patch b/patches/react-native-bottom-tabs+1.1.0.patch new file mode 100644 index 000000000..0f5331fef --- /dev/null +++ b/patches/react-native-bottom-tabs+1.1.0.patch @@ -0,0 +1,72 @@ +diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/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 @@ + self.delegate = delegate + } + +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { + 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 +--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift +@@ -67,11 +67,11 @@ + } + + func body(content: Content) -> some View { +- #if os(macOS) +- // tabViewBottomAccessory is not available on macOS ++ #if os(macOS) || os(tvOS) ++ // tabViewBottomAccessory is not available on macOS or tvOS + content + #else +- if #available(iOS 26.0, tvOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { ++ if #available(iOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { + content + .tabViewBottomAccessory { + renderBottomAccessoryView() +@@ -84,7 +84,7 @@ + + @ViewBuilder + private func renderBottomAccessoryView() -> some View { +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + if let bottomAccessoryView { + if #available(iOS 26.0, *) { + BottomAccessoryRepresentableView(view: bottomAccessoryView) +@@ -94,7 +94,7 @@ + } + } + +-#if !os(macOS) ++#if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + struct BottomAccessoryRepresentableView: PlatformViewRepresentable { + @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 +--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift +@@ -281,7 +281,7 @@ + + @ViewBuilder + func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View { +- #if compiler(>=6.2) ++ #if compiler(>=6.2) && !os(tvOS) + if #available(iOS 26.0, macOS 26.0, *) { + if let behavior { + 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 +--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift +@@ -6,7 +6,7 @@ + case onScrollUp + case onScrollDown + +-#if compiler(>=6.2) ++#if compiler(>=6.2) && !os(tvOS) + @available(iOS 26.0, macOS 26.0, *) + func convert() -> TabBarMinimizeBehavior { + #if os(macOS) diff --git a/react-native.config.js b/react-native.config.js index 6e8801ee0..3e85d555b 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -37,6 +37,11 @@ const dependencies = { ), "react-native-ios-utilities": disableForTV("react-native-ios-utilities"), "react-native-pager-view": disableForTV("react-native-pager-view"), + "react-native-track-player": disableForTV("react-native-track-player"), + "expo-location": disableForTV("expo-location"), + "react-native-glass-effect-view": disableForTV( + "react-native-glass-effect-view", + ), }; // Filter out undefined values From 6ba767a848e3c2f2cd9078d93dbb350d5e160568 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:04:23 +0100 Subject: [PATCH 002/309] fix: tvos --- bun.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index bdcea301a..dcbcbb953 100644 --- a/bun.lock +++ b/bun.lock @@ -58,7 +58,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-i18next": "16.5.3", - "react-native": "0.81.5", + "react-native": "npm:react-native-tvos@0.81.5-2", "react-native-awesome-slider": "^2.9.0", "react-native-bottom-tabs": "1.1.0", "react-native-circular-progress": "^1.4.1", @@ -540,6 +540,8 @@ "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="], + "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="], + "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="], @@ -560,8 +562,6 @@ "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], - "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="], "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="], @@ -1644,7 +1644,7 @@ "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], - "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], + "react-native": ["react-native-tvos@0.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="], "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], From 9727bec7ab2d0a8a6232bd93cfcbfe239523a8d7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:30:33 +0100 Subject: [PATCH 003/309] fix: hide header buttons --- app/(auth)/(tabs)/(custom-links)/_layout.tsx | 2 +- app/(auth)/(tabs)/(home)/_layout.tsx | 20 +++++++++++++++++--- app/(auth)/(tabs)/(watchlists)/_layout.tsx | 6 +++--- components/stacks/NestedTabPageStack.tsx | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx index 3b8a58e20..67648e05a 100644 --- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx +++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx @@ -9,7 +9,7 @@ export default function CustomMenuLayout() { ), - headerShown: true, + headerShown: !Platform.isTV, headerBlurEffect: "prominent", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx index 807530ac9..c1ad57882 100644 --- a/app/(auth)/(tabs)/(watchlists)/_layout.tsx +++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx @@ -40,7 +40,7 @@ export default function WatchlistsLayout() { name='[watchlistId]' options={{ title: "", - headerShown: true, + headerShown: !Platform.isTV, headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, @@ -51,7 +51,7 @@ export default function WatchlistsLayout() { options={{ title: t("watchlists.create_title"), presentation: "modal", - headerShown: true, + headerShown: !Platform.isTV, headerStyle: { backgroundColor: "#171717" }, headerTintColor: "white", contentStyle: { backgroundColor: "#171717" }, @@ -62,7 +62,7 @@ export default function WatchlistsLayout() { options={{ title: t("watchlists.edit_title"), presentation: "modal", - headerShown: true, + headerShown: !Platform.isTV, headerStyle: { backgroundColor: "#171717" }, headerTintColor: "white", contentStyle: { backgroundColor: "#171717" }, diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index db0f9b960..d17e6b8da 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -12,7 +12,7 @@ type ICommonScreenOptions = export const commonScreenOptions: ICommonScreenOptions = { title: "", - headerShown: true, + headerShown: !Platform.isTV, headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerBlurEffect: "none", From 7416c8297a04f29cd0871b692fb335191f8c2f8e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:30:40 +0100 Subject: [PATCH 004/309] fix: hide music bar --- app/(auth)/(tabs)/_layout.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index df1ed986d..abaaa82ef 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -11,12 +11,18 @@ import { withLayoutContext } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; -import { MiniPlayerBar } from "@/components/music/MiniPlayerBar"; -import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine"; import { Colors } from "@/constants/Colors"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; +// Music components are not available on tvOS (TrackPlayer not supported) +const MiniPlayerBar = Platform.isTV + ? () => null + : require("@/components/music/MiniPlayerBar").MiniPlayerBar; +const MusicPlaybackEngine = Platform.isTV + ? () => null + : require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine; + const { Navigator } = createNativeBottomTabNavigator(); export const NativeTabs = withLayoutContext< @@ -117,6 +123,17 @@ export default function TabLayout() { : (_e) => ({ sfSymbol: "list.dash.fill" }), }} /> + require("@/assets/icons/list.png") + : (_e) => ({ sfSymbol: "gearshape.fill" }), + }} + /> From c1e12d5898181c00ea9517ac88b3cf0583de7f63 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:30:50 +0100 Subject: [PATCH 005/309] fix: login page for tv --- app/login.tsx | 164 +------------ app/login.tv.tsx | 604 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 163 deletions(-) create mode 100644 app/login.tv.tsx diff --git a/app/login.tsx b/app/login.tsx index 33d06d41d..20c574b20 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -312,169 +312,7 @@ const Login: React.FC = () => { } }; - return Platform.isTV ? ( - // TV layout - - - {api?.basePath ? ( - // ------------ Username/Password view ------------ - - {/* Safe centered column with max width so TV doesn’t stretch too far */} - - - {serverName ? ( - <> - {`${t("login.login_to_title")} `} - {serverName} - - ) : ( - t("login.login_title") - )} - - - {api.basePath} - - - {/* Username */} - - setCredentials((prev) => ({ ...prev, username: text })) - } - onEndEditing={(e) => { - const newValue = e.nativeEvent.text; - if (newValue && newValue !== credentials.username) { - setCredentials((prev) => ({ ...prev, username: newValue })); - } - }} - value={credentials.username} - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - autoCorrect={false} - textContentType='username' - clearButtonMode='while-editing' - maxLength={500} - extraClassName='mb-4' - autoFocus={false} - blurOnSubmit={true} - /> - - {/* Password */} - - setCredentials((prev) => ({ ...prev, password: text })) - } - onEndEditing={(e) => { - const newValue = e.nativeEvent.text; - if (newValue && newValue !== credentials.password) { - setCredentials((prev) => ({ ...prev, password: newValue })); - } - }} - value={credentials.password} - secureTextEntry - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - textContentType='password' - clearButtonMode='while-editing' - maxLength={500} - extraClassName='mb-4' - autoFocus={false} - blurOnSubmit={true} - /> - - - - - - - - - - ) : ( - // ------------ Server connect view ------------ - - - - - - - - Streamyfin - - - {t("server.enter_url_to_jellyfin_server")} - - - {/* Full-width Input with clear focus ring */} - - - {/* Full-width primary button */} - - - - - {/* Lists stay full width but inside max width container */} - - { - setServerURL(server.address); - if (server.serverName) setServerName(server.serverName); - await handleConnect(server.address); - }} - /> - { - await handleConnect(s.address); - }} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - /> - - - - )} - - - ) : ( + return ( // Mobile layout { + const api = useAtomValue(apiAtom); + const navigation = useNavigation(); + const params = useLocalSearchParams(); + const { + setServer, + login, + removeServer, + initiateQuickConnect, + loginWithSavedCredential, + loginWithPassword, + } = useJellyfin(); + + const { + apiUrl: _apiUrl, + username: _username, + password: _password, + } = params as { apiUrl: string; username: string; password: string }; + + const [loadingServerCheck, setLoadingServerCheck] = useState(false); + const [loading, setLoading] = useState(false); + const [serverURL, setServerURL] = useState(_apiUrl || ""); + const [serverName, setServerName] = useState(""); + const [credentials, setCredentials] = useState<{ + username: string; + password: string; + }>({ + username: _username || "", + password: _password || "", + }); + + // Save account state + const [saveAccount, setSaveAccount] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [pendingLogin, setPendingLogin] = useState<{ + username: string; + password: string; + } | null>(null); + + // PIN/Password entry for saved accounts + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [selectedAccount, setSelectedAccount] = + useState(null); + + // Server discovery + const { + servers: discoveredServers, + isSearching, + startDiscovery, + } = useJellyfinDiscovery(); + + // Auto login from URL params + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ address: _apiUrl }); + setTimeout(() => { + if (_username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, 0); + } + })(); + }, [_apiUrl, _username, _password]); + + // Update header + useEffect(() => { + navigation.setOptions({ + headerTitle: serverName, + headerShown: false, + }); + }, [serverName, navigation]); + + const handleLogin = async () => { + const result = CredentialsSchema.safeParse(credentials); + if (!result.success) return; + + if (saveAccount) { + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + } else { + await performLogin(credentials.username, credentials.password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + const handleQuickLoginWithSavedCredential = async ( + serverUrl: string, + userId: string, + ) => { + await loginWithSavedCredential(serverUrl, userId); + }; + + const handlePasswordLogin = async ( + serverUrl: string, + username: string, + password: string, + ) => { + await loginWithPassword(serverUrl, username, password); + }; + + const handleAddAccount = (server: SavedServer) => { + setServer({ address: server.address }); + if (server.name) { + setServerName(server.name); + } + }; + + const handlePinRequired = ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setSelectedServer(server); + setSelectedAccount(account); + setPinModalVisible(true); + }; + + const handlePasswordRequired = ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setSelectedServer(server); + setSelectedAccount(account); + setPasswordModalVisible(true); + }; + + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (selectedServer && selectedAccount) { + await handleQuickLoginWithSavedCredential( + selectedServer.address, + selectedAccount.userId, + ); + } + setSelectedServer(null); + setSelectedAccount(null); + }; + + const handlePasswordSubmit = async (password: string) => { + if (selectedServer && selectedAccount) { + await handlePasswordLogin( + selectedServer.address, + selectedAccount.username, + password, + ); + } + setPasswordModalVisible(false); + setSelectedServer(null); + setSelectedAccount(null); + }; + + const handleForgotPIN = async () => { + if (selectedServer) { + setSelectedServer(null); + setSelectedAccount(null); + setPinModalVisible(false); + } + }; + + const checkUrl = useCallback(async (url: string) => { + setLoadingServerCheck(true); + const baseUrl = url.replace(/^https?:\/\//i, ""); + const protocols = ["https", "http"]; + try { + return checkHttp(baseUrl, protocols); + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + return undefined; + } finally { + setLoadingServerCheck(false); + } + }, []); + + async function checkHttp(baseUrl: string, protocols: string[]) { + for (const protocol of protocols) { + try { + const response = await fetch( + `${protocol}://${baseUrl}/System/Info/Public`, + { mode: "cors" }, + ); + if (response.ok) { + const data = (await response.json()) as PublicSystemInfo; + const serverVersion = data.Version?.split("."); + if (serverVersion && +serverVersion[0] <= 10) { + if (+serverVersion[1] < 10) { + Alert.alert( + t("login.too_old_server_text"), + t("login.too_old_server_description"), + ); + throw new Error("Server too old"); + } + } + setServerName(data.ServerName || ""); + return `${protocol}://${baseUrl}`; + } + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + } + } + return undefined; + } + + const handleConnect = useCallback(async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + } catch {} + }, []); + + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert( + t("login.quick_connect"), + t("login.enter_code_to_login", { code: code }), + [{ text: t("login.got_it") }], + ); + } + } catch (_error) { + Alert.alert( + t("login.error_title"), + t("login.failed_to_initiate_quick_connect"), + ); + } + }; + + return ( + + + {api?.basePath ? ( + // ==================== CREDENTIALS SCREEN ==================== + + + {/* Back Button */} + removeServer()} + style={{ + flexDirection: "row", + alignItems: "center", + marginBottom: 40, + }} + > + + + {t("login.change_server")} + + + + {/* Title */} + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + + {api.basePath} + + + {/* Username Input */} + + + setCredentials((prev) => ({ ...prev, username: text })) + } + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + returnKeyType='next' + hasTVPreferredFocus + /> + + + {/* Password Input */} + + + setCredentials((prev) => ({ ...prev, password: text })) + } + secureTextEntry + autoCapitalize='none' + textContentType='password' + returnKeyType='done' + /> + + + {/* Save Account Toggle */} + + + + + {/* Login Button */} + + + + + {/* Quick Connect Button */} + + + + ) : ( + // ==================== SERVER SELECTION SCREEN ==================== + + + {/* Logo */} + + + + + {/* Title */} + + Streamyfin + + + {t("server.enter_url_to_jellyfin_server")} + + + {/* Server URL Input */} + + + + + {/* Connect Button */} + + + + + {/* Server Discovery */} + + + + + {/* Discovered Servers */} + {discoveredServers.length > 0 && ( + + + {t("server.servers")} + + {discoveredServers.map((server) => ( + { + setServerURL(server.address); + if (server.serverName) { + setServerName(server.serverName); + } + handleConnect(server.address); + }} + /> + ))} + + )} + + {/* Previous Servers */} + handleConnect(s.address)} + onQuickLogin={handleQuickLoginWithSavedCredential} + onPasswordLogin={handlePasswordLogin} + onAddAccount={handleAddAccount} + onPinRequired={handlePinRequired} + onPasswordRequired={handlePasswordRequired} + /> + + + )} + + + {/* Save Account Modal */} + { + setShowSaveModal(false); + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || credentials.username} + /> + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={handleForgotPIN} + serverUrl={selectedServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> + + ); +}; + +export default TVLogin; From ad5148daadb76bac3c45c871103bd942cdcaf3cc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:31:37 +0100 Subject: [PATCH 006/309] fix: login stuff for tv --- components/login/TVAccountCard.tsx | 151 +++++++++++ components/login/TVInput.tsx | 136 ++++++++++ components/login/TVPreviousServersList.tsx | 284 +++++++++++++++++++++ components/login/TVSaveAccountToggle.tsx | 100 ++++++++ components/login/TVServerCard.tsx | 148 +++++++++++ 5 files changed, 819 insertions(+) create mode 100644 components/login/TVAccountCard.tsx create mode 100644 components/login/TVInput.tsx create mode 100644 components/login/TVPreviousServersList.tsx create mode 100644 components/login/TVSaveAccountToggle.tsx create mode 100644 components/login/TVServerCard.tsx diff --git a/components/login/TVAccountCard.tsx b/components/login/TVAccountCard.tsx new file mode 100644 index 000000000..2ef2d9132 --- /dev/null +++ b/components/login/TVAccountCard.tsx @@ -0,0 +1,151 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; + +interface TVAccountCardProps { + account: SavedServerAccount; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVAccountCard: React.FC = ({ + account, + onPress, + onLongPress, + hasTVPreferredFocus, +}) => { + const { t } = useTranslation(); + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.03 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const getSecurityText = (): string => { + switch (account.securityType) { + case "pin": + return t("save_account.pin_code"); + case "password": + return t("save_account.password"); + default: + return t("save_account.no_protection"); + } + }; + + return ( + + + + {/* Avatar */} + + + + + {/* Account Info */} + + + {account.username} + + + {getSecurityText()} + + + + {/* Security Icon */} + + + + + ); +}; diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx new file mode 100644 index 000000000..7ecd57f4a --- /dev/null +++ b/components/login/TVInput.tsx @@ -0,0 +1,136 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + TextInput, + type TextInputProps, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; + +interface TVInputProps extends TextInputProps { + label?: string; + hasTVPreferredFocus?: boolean; +} + +export const TVInput: React.FC = ({ + label, + hasTVPreferredFocus, + style, + ...props +}) => { + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 200, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + + {label && ( + + {label} + + )} + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {/* Outer glow layer - only visible when focused */} + {isFocused && ( + + )} + + {/* Main input container */} + + {/* Inner highlight bar when focused */} + {isFocused && ( + + )} + + + + + + + ); +}; diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx new file mode 100644 index 000000000..aa65b5dd3 --- /dev/null +++ b/components/login/TVPreviousServersList.tsx @@ -0,0 +1,284 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Modal, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { + deleteAccountCredential, + getPreviousServers, + type SavedServer, + type SavedServerAccount, +} from "@/utils/secureCredentials"; +import { TVAccountCard } from "./TVAccountCard"; +import { TVServerCard } from "./TVServerCard"; + +interface TVPreviousServersListProps { + onServerSelect: (server: SavedServer) => void; + onQuickLogin?: (serverUrl: string, userId: string) => Promise; + onPasswordLogin?: ( + serverUrl: string, + username: string, + password: string, + ) => Promise; + onAddAccount?: (server: SavedServer) => void; + onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void; + onPasswordRequired?: ( + server: SavedServer, + account: SavedServerAccount, + ) => void; +} + +export const TVPreviousServersList: React.FC = ({ + onServerSelect, + onQuickLogin, + onAddAccount, + onPinRequired, + onPasswordRequired, +}) => { + const { t } = useTranslation(); + const [_previousServers, setPreviousServers] = + useMMKVString("previousServers"); + const [loadingServer, setLoadingServer] = useState(null); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [showAccountsModal, setShowAccountsModal] = useState(false); + + const previousServers = useMemo(() => { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + }, [_previousServers]); + + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + const handleAccountLogin = async ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setShowAccountsModal(false); + + switch (account.securityType) { + case "none": + if (onQuickLogin) { + setLoadingServer(server.address); + try { + await onQuickLogin(server.address, account.userId); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], + ); + } finally { + setLoadingServer(null); + } + } + break; + + case "pin": + if (onPinRequired) { + onPinRequired(server, account); + } + break; + + case "password": + if (onPasswordRequired) { + onPasswordRequired(server, account); + } + break; + } + }; + + const handleServerPress = (server: SavedServer) => { + if (loadingServer) return; + + const accountCount = server.accounts?.length || 0; + + if (accountCount === 0) { + onServerSelect(server); + } else if (accountCount === 1) { + handleAccountLogin(server, server.accounts[0]); + } else { + setSelectedServer(server); + setShowAccountsModal(true); + } + }; + + const getServerSubtitle = (server: SavedServer): string | undefined => { + const accountCount = server.accounts?.length || 0; + + if (accountCount > 1) { + return t("server.accounts_count", { count: accountCount }); + } + if (accountCount === 1) { + return `${server.accounts[0].username} • ${t("server.saved")}`; + } + return server.name ? server.address : undefined; + }; + + const getSecurityIcon = ( + server: SavedServer, + ): keyof typeof Ionicons.glyphMap | null => { + const accountCount = server.accounts?.length || 0; + if (accountCount === 0) return null; + + if (accountCount > 1) { + return "people"; + } + + const account = server.accounts[0]; + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const handleDeleteAccount = async (account: SavedServerAccount) => { + if (!selectedServer) return; + + Alert.alert( + t("server.remove_saved_login"), + t("server.remove_account_description", { username: account.username }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.remove"), + style: "destructive", + onPress: async () => { + await deleteAccountCredential( + selectedServer.address, + account.userId, + ); + refreshServers(); + if (selectedServer.accounts.length <= 1) { + setShowAccountsModal(false); + } + }, + }, + ], + ); + }; + + if (!previousServers.length) return null; + + return ( + + + {t("server.previous_servers")} + + + + {previousServers.map((server) => ( + handleServerPress(server)} + /> + ))} + + + {/* TV Account Selection Modal */} + setShowAccountsModal(false)} + > + + + + {t("server.select_account")} + + + {selectedServer?.name || selectedServer?.address} + + + + {selectedServer?.accounts.map((account, index) => ( + + selectedServer && + handleAccountLogin(selectedServer, account) + } + onLongPress={() => handleDeleteAccount(account)} + hasTVPreferredFocus={index === 0} + /> + ))} + + + + + + + + + + + ); +}; diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx new file mode 100644 index 000000000..5d07bb8dd --- /dev/null +++ b/components/login/TVSaveAccountToggle.tsx @@ -0,0 +1,100 @@ +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, Switch, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; + +interface TVSaveAccountToggleProps { + value: boolean; + onValueChange: (value: boolean) => void; + label: string; + hasTVPreferredFocus?: boolean; +} + +export const TVSaveAccountToggle: React.FC = ({ + value, + onValueChange, + label, + hasTVPreferredFocus, +}) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.03 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + onValueChange(!value)} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + + + + ); +}; diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx new file mode 100644 index 000000000..7178e8690 --- /dev/null +++ b/components/login/TVServerCard.tsx @@ -0,0 +1,148 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Easing, + Pressable, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; + +interface TVServerCardProps { + title: string; + subtitle?: string; + securityIcon?: keyof typeof Ionicons.glyphMap | null; + isLoading?: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVServerCard: React.FC = ({ + title, + subtitle, + securityIcon, + isLoading, + onPress, + hasTVPreferredFocus, +}) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.7 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + {isLoading ? ( + + ) : securityIcon ? ( + + + + + ) : ( + + )} + + + + + ); +}; From 6d2e897c9f5be43490d11aed9e509b128ee62b03 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:31:44 +0100 Subject: [PATCH 007/309] fix: badge for tv --- components/Badge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Badge.tsx b/components/Badge.tsx index b33fff2b6..a694e3a5f 100644 --- a/components/Badge.tsx +++ b/components/Badge.tsx @@ -28,7 +28,7 @@ export const Badge: React.FC = ({ ); - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( From 6216e7fdb7cda71183dc83601ba8a6b5c09148b5 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:31:53 +0100 Subject: [PATCH 008/309] fix: items content for tv --- components/ItemContent.tsx | 411 +++++++++++----------- components/ItemContent.tv.tsx | 637 ++++++++++++++++++++++++++++++++++ 2 files changed, 850 insertions(+), 198 deletions(-) create mode 100644 components/ItemContent.tv.tsx diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1b2cbbacc..7a57c4d83 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections"; import { MediaSourceButton } from "@/components/MediaSourceButton"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; -// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; @@ -36,6 +35,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; +const ItemContentTV = Platform.isTV + ? require("./ItemContent.tv").ItemContentTV + : null; export type SelectedOptions = { bitrate: Bitrate; @@ -49,225 +51,238 @@ interface ItemContentProps { itemWithSources?: BaseItemDto | null; } -export const ItemContent: React.FC = React.memo( - ({ item, itemWithSources }) => { - const [api] = useAtom(apiAtom); - const isOffline = useOfflineMode(); - const { settings } = useSettings(); - const { orientation } = useOrientation(); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const [user] = useAtom(userAtom); +// Mobile-specific implementation +const ItemContentMobile: React.FC = ({ + item, + itemWithSources, +}) => { + const [api] = useAtom(apiAtom); + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const { orientation } = useOrientation(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [user] = useAtom(userAtom); - const itemColors = useImageColorsReturn({ item }); + const itemColors = useImageColorsReturn({ item }); - const [loadingLogo, setLoadingLogo] = useState(true); - const [headerHeight, setHeaderHeight] = useState(350); + const [loadingLogo, setLoadingLogo] = useState(true); + const [headerHeight, setHeaderHeight] = useState(350); - const [selectedOptions, setSelectedOptions] = useState< - SelectedOptions | undefined - >(undefined); + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); - // Use itemWithSources for play settings since it has MediaSources data - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(itemWithSources ?? item, settings); + // Use itemWithSources for play settings since it has MediaSources data + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(itemWithSources ?? item, settings); - const logoUrl = useMemo( - () => (item ? getLogoImageUrlById({ api, item }) : null), - [api, item], - ); + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); - const onLogoLoad = React.useCallback(() => { - setLoadingLogo(false); - }, []); + const onLogoLoad = React.useCallback(() => { + setLoadingLogo(false); + }, []); - const loading = useMemo(() => { - return Boolean(logoUrl && loadingLogo); - }, [loadingLogo, logoUrl]); + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); - // Needs to automatically change the selected to the default values for default indexes. - useEffect(() => { - setSelectedOptions(() => ({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource ?? undefined, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, - })); - }, [ - defaultAudioIndex, - defaultBitrate, - defaultSubtitleIndex, - defaultMediaSource, - ]); + // Needs to automatically change the selected to the default values for default indexes. + useEffect(() => { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + ]); - useEffect(() => { - if (!Platform.isTV && itemWithSources) { - navigation.setOptions({ - headerRight: () => - item && - (Platform.OS === "ios" ? ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + useEffect(() => { + if (!Platform.isTV && itemWithSources) { + navigation.setOptions({ + headerRight: () => + item && + (Platform.OS === "ios" ? ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - ) : ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - )), - }); - } - }, [ - item, - navigation, - user, - itemWithSources, - settings.hideRemoteSessionButton, - settings.streamyStatsServerUrl, - settings.hideWatchlistsTab, - ]); - - useEffect(() => { - if (item) { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - } - }, [item, orientation]); - - if (!item || !selectedOptions) return null; - - return ( - - - + + )} - } - logo={ - logoUrl ? ( - - ) : ( - - ) - } - > - - - + ) : ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + + )} - - + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + + )} + + )} + + )), + }); + } + }, [ + item, + navigation, + user, + itemWithSources, + settings.hideRemoteSessionButton, + settings.streamyStatsServerUrl, + settings.hideWatchlistsTab, + ]); + + useEffect(() => { + if (item) { + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + } + }, [item, orientation]); + + if (!item || !selectedOptions) return null; + + return ( + + + + + } + logo={ + logoUrl ? ( + + ) : ( + + ) + } + > + + + + + + + + {!isOffline && ( + - - {!isOffline && ( - - )} - + )} - {item.Type === "Episode" && ( - + + {item.Type === "Episode" && ( + + )} + + {!isOffline && + selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + )} - {!isOffline && - selectedOptions.mediaSource?.MediaStreams && - selectedOptions.mediaSource.MediaStreams.length > 0 && ( - + + + {item.Type !== "Program" && ( + <> + {item.Type === "Episode" && !isOffline && ( + )} - + - {item.Type !== "Program" && ( - <> - {item.Type === "Episode" && !isOffline && ( - - )} + {!isOffline && } + + )} + + + + ); +}; - +// Memoize the mobile component +const MemoizedItemContentMobile = React.memo(ItemContentMobile); - {!isOffline && } - - )} - - - - ); - }, -); +// Exported component that renders TV or mobile version based on platform +export const ItemContent: React.FC = (props) => { + if (Platform.isTV && ItemContentTV) { + return ; + } + return ; +}; diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx new file mode 100644 index 000000000..8f56f26fe --- /dev/null +++ b/components/ItemContent.tv.tsx @@ -0,0 +1,637 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtom } from "jotai"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + Easing, + Pressable, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Badge } from "@/components/Badge"; +import { type Bitrate } from "@/components/BitrateSelector"; +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +import useRouter from "@/hooks/useAppRouter"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; +import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +export type SelectedOptions = { + bitrate: Bitrate; + mediaSource: MediaSourceInfo | undefined; + audioIndex: number | undefined; + subtitleIndex: number; +}; + +interface ItemContentTVProps { + item: BaseItemDto; + itemWithSources?: BaseItemDto | null; +} + +// Focusable button component for TV with Apple TV-style animations +const TVFocusableButton: React.FC<{ + onPress: () => void; + children: React.ReactNode; + hasTVPreferredFocus?: boolean; + style?: any; + variant?: "primary" | "secondary"; +}> = ({ + onPress, + children, + hasTVPreferredFocus, + style, + variant = "primary", +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isPrimary = variant === "primary"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {children} + + + + ); +}; + +// Info row component for metadata display +const _InfoRow: React.FC<{ label: string; value: string }> = ({ + label, + value, +}) => ( + + {label} + {value} + +); + +export const ItemContentTV: React.FC = React.memo( + ({ item, itemWithSources }) => { + const [api] = useAtom(apiAtom); + const [_user] = useAtom(userAtom); + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { t } = useTranslation(); + + const _itemColors = useImageColorsReturn({ item }); + + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); + + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(itemWithSources ?? item, settings); + + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); + + // Set default play options + useEffect(() => { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + ]); + + const handlePlay = () => { + if (!item || !selectedOptions) return; + + const queryParams = new URLSearchParams({ + itemId: item.Id!, + audioIndex: selectedOptions.audioIndex?.toString() ?? "", + subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", + mediaSourceId: selectedOptions.mediaSource?.Id ?? "", + bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + offline: isOffline ? "true" : "false", + }); + + router.push(`/player/direct-player?${queryParams.toString()}`); + }; + + // Format year and duration + const year = item.ProductionYear; + const duration = item.RunTimeTicks + ? runtimeTicksToMinutes(item.RunTimeTicks) + : null; + const hasProgress = + item.UserData?.PlaybackPositionTicks && + item.UserData.PlaybackPositionTicks > 0; + const remainingTime = hasProgress + ? runtimeTicksToMinutes( + (item.RunTimeTicks || 0) - + (item.UserData?.PlaybackPositionTicks || 0), + ) + : null; + + // Get director + const director = item.People?.find((p) => p.Type === "Director"); + + // Get cast (first 3) + const cast = item.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); + + if (!item || !selectedOptions) return null; + + return ( + + {/* Full-screen backdrop */} + + + {/* Gradient overlays for readability */} + + + + + {/* Main content area */} + + {/* Top section - Logo/Title + Metadata */} + + {/* Left side - Poster */} + + + + + + + {/* Right side - Content */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {item.Name} + + )} + + {/* Episode info for TV shows */} + {item.Type === "Episode" && ( + + + {item.SeriesName} + + + S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name} + + + )} + + {/* Metadata badges row */} + + {year != null && ( + {year} + )} + {duration && ( + + {duration} + + )} + {item.OfficialRating && ( + + )} + {item.CommunityRating != null && ( + } + /> + )} + + + {/* Genres */} + {item.Genres && item.Genres.length > 0 && ( + + + + )} + + {/* Overview */} + {item.Overview && ( + + {item.Overview} + + )} + + {/* Action buttons */} + + + + + {hasProgress + ? `${remainingTime} ${t("item_card.left")}` + : t("common.play")} + + + + {!isOffline && item.Type !== "Program" && ( + { + // Info/More options action + }} + variant='secondary' + > + + + {t("item_card.more_info")} + + + )} + + + {/* Progress bar (if partially watched) */} + {hasProgress && item.RunTimeTicks && ( + + + + + + )} + + + + {/* Additional info section */} + + {/* Cast & Crew */} + {(director || (cast && cast.length > 0)) && ( + + + {t("item_card.cast_and_crew")} + + + {director && ( + + + {t("item_card.director")} + + + {director.Name} + + + )} + {cast && cast.length > 0 && ( + + + {t("item_card.cast")} + + + {cast.map((c) => c.Name).join(", ")} + + + )} + + + )} + + {/* Technical details */} + {selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + + + {t("item_card.technical_details")} + + + {/* Video info */} + {(() => { + const videoStream = + selectedOptions.mediaSource?.MediaStreams?.find( + (s) => s.Type === "Video", + ); + if (!videoStream) return null; + return ( + + + Video + + + {videoStream.DisplayTitle || + `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} + + + ); + })()} + {/* Audio info */} + {(() => { + const audioStream = + selectedOptions.mediaSource?.MediaStreams?.find( + (s) => s.Type === "Audio", + ); + if (!audioStream) return null; + return ( + + + Audio + + + {audioStream.DisplayTitle || + `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} + + + ); + })()} + + + )} + + + + ); + }, +); From bd9467b09ec8222d359fde3d72c36ded64f80428 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:32:02 +0100 Subject: [PATCH 009/309] fix: remove music provider for tv --- providers/MusicPlayerProvider.tsx | 132 ++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index 63871a22b..71bce6e11 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -15,12 +15,7 @@ import React, { useRef, useState, } from "react"; -import TrackPlayer, { - Capability, - type Progress, - RepeatMode as TPRepeatMode, - type Track, -} from "react-native-track-player"; +import { Platform } from "react-native"; import { downloadTrack, getLocalPath, @@ -34,6 +29,22 @@ import { settingsAtom } from "@/utils/atoms/settings"; import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { storage } from "@/utils/mmkv"; +// Conditionally import TrackPlayer only on non-TV platforms +// This prevents the native module from being loaded on TV where it doesn't exist +const TrackPlayer = Platform.isTV + ? null + : require("react-native-track-player").default; + +const TrackPlayerModule = Platform.isTV + ? null + : require("react-native-track-player"); + +// Extract types and enums from the module (only available on non-TV) +const Capability = TrackPlayerModule?.Capability; +const TPRepeatMode = TrackPlayerModule?.RepeatMode; +type Track = NonNullable["Track"]; +type Progress = NonNullable["Progress"]; + // Storage keys const STORAGE_KEYS = { QUEUE: "music_player_queue", @@ -116,6 +127,28 @@ interface MusicPlayerContextType extends MusicPlayerState { triggerLookahead: () => void; } +const defaultState: MusicPlayerState = { + currentTrack: null, + queue: [], + originalQueue: [], + queueIndex: 0, + isPlaying: false, + isLoading: false, + loadingTrackId: null, + progress: 0, + duration: 0, + streamUrl: null, + playSessionId: null, + repeatMode: "off", + shuffleEnabled: false, + mediaSource: null, + isTranscoding: false, + trackMediaInfoMap: {}, +}; + +// No-op function for TV stub +const noop = () => {}; + const MusicPlayerContext = createContext( undefined, ); @@ -132,6 +165,48 @@ interface MusicPlayerProviderProps { children: ReactNode; } +// Stub provider for tvOS - music playback is not supported +const TVMusicPlayerProvider: React.FC = ({ + children, +}) => { + const value: MusicPlayerContextType = { + ...defaultState, + playTrack: noop, + playQueue: noop, + playAlbum: noop, + playPlaylist: noop, + pause: noop, + resume: noop, + togglePlayPause: noop, + next: noop, + previous: noop, + seek: noop, + stop: noop, + addToQueue: noop, + playNext: noop, + removeFromQueue: noop, + moveInQueue: noop, + reorderQueue: noop, + clearQueue: noop, + jumpToIndex: noop, + setRepeatMode: noop, + toggleShuffle: noop, + setProgress: noop, + setDuration: noop, + setIsPlaying: noop, + reportProgress: noop, + onTrackEnd: noop, + syncFromTrackPlayer: noop, + triggerLookahead: noop, + }; + + return ( + + {children} + + ); +}; + // Persistence helpers const saveQueueToStorage = (queue: BaseItemDto[], queueIndex: number) => { try { @@ -272,7 +347,8 @@ const itemToTrack = ( }; }; -export const MusicPlayerProvider: React.FC = ({ +// Full implementation for non-TV platforms +const MobileMusicPlayerProvider: React.FC = ({ children, }) => { const api = useAtomValue(apiAtom); @@ -306,6 +382,8 @@ export const MusicPlayerProvider: React.FC = ({ // Setup TrackPlayer and AudioStorage useEffect(() => { + if (!TrackPlayer) return; + const setupPlayer = async () => { if (playerSetupRef.current) return; @@ -354,19 +432,21 @@ export const MusicPlayerProvider: React.FC = ({ // Sync repeat mode to TrackPlayer useEffect(() => { + if (!TrackPlayer) return; + const syncRepeatMode = async () => { if (!playerSetupRef.current) return; - let tpRepeatMode: TPRepeatMode; + let tpRepeatMode: typeof TPRepeatMode; switch (state.repeatMode) { case "one": - tpRepeatMode = TPRepeatMode.Track; + tpRepeatMode = TPRepeatMode?.Track; break; case "all": - tpRepeatMode = TPRepeatMode.Queue; + tpRepeatMode = TPRepeatMode?.Queue; break; default: - tpRepeatMode = TPRepeatMode.Off; + tpRepeatMode = TPRepeatMode?.Off; } await TrackPlayer.setRepeatMode(tpRepeatMode); }; @@ -553,14 +633,13 @@ export const MusicPlayerProvider: React.FC = ({ // Load remaining tracks in the background without blocking playback const loadRemainingTracksInBackground = useCallback( async (queue: BaseItemDto[], startIndex: number, preferLocal: boolean) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const mediaInfoMap: Record = {}; const failedItemIds: string[] = []; // Track items that failed to prepare // Process tracks BEFORE the start index (insert at position 0, pushing current track forward) const beforeTracks: Track[] = []; - const beforeSuccessIds: string[] = []; // Track successful IDs to maintain order for (let i = 0; i < startIndex; i++) { const item = queue[i]; if (!item.Id) continue; @@ -568,7 +647,6 @@ export const MusicPlayerProvider: React.FC = ({ const prepared = await prepareTrack(item, preferLocal); if (prepared) { beforeTracks.push(prepared.track); - beforeSuccessIds.push(item.Id); if (prepared.mediaInfo) { mediaInfoMap[item.Id] = prepared.mediaInfo; } @@ -641,7 +719,7 @@ export const MusicPlayerProvider: React.FC = ({ const loadAndPlayQueue = useCallback( async (queue: BaseItemDto[], startIndex: number) => { - if (!api || !user?.Id || queue.length === 0) return; + if (!api || !user?.Id || queue.length === 0 || !TrackPlayer) return; const preferLocal = settings?.preferLocalAudio ?? true; @@ -856,11 +934,13 @@ export const MusicPlayerProvider: React.FC = ({ ); const pause = useCallback(async () => { + if (!TrackPlayer) return; await TrackPlayer.pause(); setState((prev) => ({ ...prev, isPlaying: false })); }, []); const resume = useCallback(async () => { + if (!TrackPlayer) return; if (!state.streamUrl && state.currentTrack && api && user?.Id) { // Need to load the track first (e.g., after app restart) const result = await getAudioStreamUrl( @@ -905,6 +985,7 @@ export const MusicPlayerProvider: React.FC = ({ }, [state.isPlaying, pause, resume]); const next = useCallback(async () => { + if (!TrackPlayer) return; const currentIndex = await TrackPlayer.getActiveTrackIndex(); const queueLength = (await TrackPlayer.getQueue()).length; @@ -964,6 +1045,7 @@ export const MusicPlayerProvider: React.FC = ({ ]); const previous = useCallback(async () => { + if (!TrackPlayer) return; const position = await TrackPlayer.getProgress().then( (p: Progress) => p.position, ); @@ -1033,11 +1115,13 @@ export const MusicPlayerProvider: React.FC = ({ ]); const seek = useCallback(async (position: number) => { + if (!TrackPlayer) return; await TrackPlayer.seekTo(position); setState((prev) => ({ ...prev, progress: position })); }, []); const stop = useCallback(async () => { + if (!TrackPlayer) return; if (state.currentTrack && state.playSessionId) { reportPlaybackStopped( state.currentTrack, @@ -1087,7 +1171,7 @@ export const MusicPlayerProvider: React.FC = ({ // Queue management const addToQueue = useCallback( async (tracks: BaseItemDto | BaseItemDto[]) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; const preferLocal = settings?.preferLocalAudio ?? true; @@ -1120,7 +1204,7 @@ export const MusicPlayerProvider: React.FC = ({ const playNext = useCallback( async (tracks: BaseItemDto | BaseItemDto[]) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; const currentIndex = await TrackPlayer.getActiveTrackIndex(); @@ -1168,6 +1252,7 @@ export const MusicPlayerProvider: React.FC = ({ ); const removeFromQueue = useCallback(async (index: number) => { + if (!TrackPlayer) return; const queueLength = (await TrackPlayer.getQueue()).length; const currentIndex = await TrackPlayer.getActiveTrackIndex(); @@ -1201,6 +1286,7 @@ export const MusicPlayerProvider: React.FC = ({ const moveInQueue = useCallback( async (fromIndex: number, toIndex: number) => { + if (!TrackPlayer) return; const queue = await TrackPlayer.getQueue(); if ( fromIndex < 0 || @@ -1241,6 +1327,7 @@ export const MusicPlayerProvider: React.FC = ({ // Reorder queue with a new array (used by drag-to-reorder UI) const reorderQueue = useCallback( async (newQueue: BaseItemDto[]) => { + if (!TrackPlayer) return; // Find where the current track ended up in the new order const currentTrackId = state.currentTrack?.Id; const newIndex = currentTrackId @@ -1253,7 +1340,7 @@ export const MusicPlayerProvider: React.FC = ({ // Create a map of trackId -> current TrackPlayer index const currentPositions = new Map(); - tpQueue.forEach((track, idx) => { + tpQueue.forEach((track: Track, idx: number) => { currentPositions.set(track.id, idx); }); @@ -1296,6 +1383,7 @@ export const MusicPlayerProvider: React.FC = ({ ); const clearQueue = useCallback(async () => { + if (!TrackPlayer) return; const currentIndex = await TrackPlayer.getActiveTrackIndex(); const queue = await TrackPlayer.getQueue(); @@ -1325,6 +1413,7 @@ export const MusicPlayerProvider: React.FC = ({ const jumpToIndex = useCallback( async (index: number) => { + if (!TrackPlayer) return; if ( index < 0 || index >= state.queue.length || @@ -1460,6 +1549,7 @@ export const MusicPlayerProvider: React.FC = ({ // Sync state from TrackPlayer (called when active track changes) // Uses ID-based lookup instead of index to handle queue mismatches const syncFromTrackPlayer = useCallback(async () => { + if (!TrackPlayer) return; const activeTrack = await TrackPlayer.getActiveTrack(); if (!activeTrack?.id) return; @@ -1476,6 +1566,7 @@ export const MusicPlayerProvider: React.FC = ({ // Called by playback engine when track ends const onTrackEnd = useCallback(() => { + if (!TrackPlayer) return; if (state.repeatMode === "one") { TrackPlayer.seekTo(0); TrackPlayer.play(); @@ -1485,6 +1576,7 @@ export const MusicPlayerProvider: React.FC = ({ // Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition) const triggerLookahead = useCallback(async () => { + if (!TrackPlayer) return; // Check if caching is enabled in settings if (settings?.audioLookaheadEnabled === false) return; if (!api || !user?.Id) return; @@ -1598,3 +1690,7 @@ export const MusicPlayerProvider: React.FC = ({ ); }; + +// Export the appropriate provider based on platform +export const MusicPlayerProvider: React.FC = + Platform.isTV ? TVMusicPlayerProvider : MobileMusicPlayerProvider; From 87169480a15940d546e4f30f08442a03581a78f0 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:32:05 +0100 Subject: [PATCH 010/309] chore --- translations/en.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/translations/en.json b/translations/en.json index 3fe9efb66..dc24f9b06 100644 --- a/translations/en.json +++ b/translations/en.json @@ -630,6 +630,11 @@ "subtitles": "Subtitle", "show_more": "Show More", "show_less": "Show Less", + "left": "left", + "more_info": "More Info", + "director": "Director", + "cast": "Cast", + "technical_details": "Technical Details", "appeared_in": "Appeared In", "could_not_load_item": "Could Not Load Item", "none": "None", From 15e4c18d5462ac1c1e7f467c8bd4dffb30536d5a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:42:53 +0100 Subject: [PATCH 011/309] fix(tvos): settings --- app/(auth)/(tabs)/(home)/settings.tsx | 17 +- app/(auth)/(tabs)/(home)/settings.tv.tsx | 719 +++++++++++++++++++++++ app/(auth)/(tabs)/(settings)/_layout.tsx | 21 + app/(auth)/(tabs)/(settings)/index.tsx | 5 + 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings.tv.tsx create mode 100644 app/(auth)/(tabs)/(settings)/_layout.tsx create mode 100644 app/(auth)/(tabs)/(settings)/index.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 76675ae84..1ed36fe2f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -14,7 +14,11 @@ import { UserInfo } from "@/components/settings/UserInfo"; import useRouter from "@/hooks/useAppRouter"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -export default function settings() { +// TV-specific settings component +const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; + +// Mobile settings component +function SettingsMobile() { const router = useRouter(); const insets = useSafeAreaInsets(); const [_user] = useAtom(userAtom); @@ -104,8 +108,17 @@ export default function settings() { - {!Platform.isTV && } + ); } + +export default function settings() { + // Use TV settings component on TV platforms + if (Platform.isTV && SettingsTV) { + return ; + } + + return ; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx new file mode 100644 index 000000000..24a086486 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -0,0 +1,719 @@ +import { Ionicons } from "@expo/vector-icons"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useAtom } from "jotai"; +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; + +// TV-optimized focusable row component +const TVSettingsRow: React.FC<{ + label: string; + value: string; + onPress?: () => void; + isFirst?: boolean; + showChevron?: boolean; +}> = ({ label, value, onPress, isFirst, showChevron = true }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + {value} + + {showChevron && ( + + )} + + + + ); +}; + +// TV-optimized toggle row component +const TVSettingsToggle: React.FC<{ + label: string; + value: boolean; + onToggle: (value: boolean) => void; + isFirst?: boolean; +}> = ({ label, value, onToggle, isFirst }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + onToggle(!value)} + onFocus={() => { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + + + + ); +}; + +// TV-optimized stepper row component +const TVSettingsStepper: React.FC<{ + label: string; + value: number; + onDecrease: () => void; + onIncrease: () => void; + formatValue?: (value: number) => string; + isFirst?: boolean; +}> = ({ label, value, onDecrease, onIncrease, formatValue, isFirst }) => { + const [focused, setFocused] = useState(false); + const [buttonFocused, setButtonFocused] = useState<"minus" | "plus" | null>( + null, + ); + const scale = useRef(new Animated.Value(1)).current; + const minusScale = useRef(new Animated.Value(1)).current; + const plusScale = useRef(new Animated.Value(1)).current; + + const animateTo = (ref: Animated.Value, v: number) => + Animated.timing(ref, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const displayValue = formatValue ? formatValue(value) : String(value); + + return ( + + { + setFocused(true); + animateTo(scale, 1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(scale, 1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + + { + setButtonFocused("minus"); + animateTo(minusScale, 1.1); + }} + onBlur={() => { + setButtonFocused(null); + animateTo(minusScale, 1); + }} + > + + + + + + {displayValue} + + { + setButtonFocused("plus"); + animateTo(plusScale, 1.1); + }} + onBlur={() => { + setButtonFocused(null); + animateTo(plusScale, 1); + }} + > + + + + + + + ); +}; + +// TV-optimized dropdown selector +const TVSettingsDropdown: React.FC<{ + label: string; + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isFirst?: boolean; +}> = ({ label, options, selectedValue, onSelect, isFirst }) => { + const [expanded, setExpanded] = useState(false); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const selectedLabel = + options.find((o) => o.value === selectedValue)?.label || selectedValue; + + if (expanded) { + return ( + + + {label} + + {options.map((option, index) => ( + { + onSelect(option.value); + setExpanded(false); + }} + isFirst={index === 0} + /> + ))} + + ); + } + + return ( + setExpanded(true)} + onFocus={() => { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + {selectedLabel} + + + + + + ); +}; + +// Dropdown option component +const TVDropdownOption: React.FC<{ + label: string; + selected: boolean; + onSelect: () => void; + isFirst?: boolean; +}> = ({ label, selected, onSelect, isFirst }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + {selected && } + + + ); +}; + +// Section header component +const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( + + {title} + +); + +// Logout button component +const TVLogoutButton: React.FC<{ onPress: () => void }> = ({ onPress }) => { + const { t } = useTranslation(); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + + {t("home.settings.log_out_button")} + + + + + ); +}; + +export default function SettingsTV() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { settings, updateSettings } = useSettings(); + const { logout } = useJellyfin(); + const [user] = useAtom(userAtom); + const [api] = useAtom(apiAtom); + + // Audio transcoding options + const audioTranscodeModeOptions = [ + { + label: t("home.settings.audio.transcode_mode.auto"), + value: AudioTranscodeMode.Auto, + }, + { + label: t("home.settings.audio.transcode_mode.stereo"), + value: AudioTranscodeMode.ForceStereo, + }, + { + label: t("home.settings.audio.transcode_mode.5_1"), + value: AudioTranscodeMode.Allow51, + }, + { + label: t("home.settings.audio.transcode_mode.passthrough"), + value: AudioTranscodeMode.AllowAll, + }, + ]; + + // Subtitle mode options + const subtitleModeOptions = [ + { + label: t("home.settings.subtitles.modes.Default"), + value: SubtitlePlaybackMode.Default, + }, + { + label: t("home.settings.subtitles.modes.Smart"), + value: SubtitlePlaybackMode.Smart, + }, + { + label: t("home.settings.subtitles.modes.OnlyForced"), + value: SubtitlePlaybackMode.OnlyForced, + }, + { + label: t("home.settings.subtitles.modes.Always"), + value: SubtitlePlaybackMode.Always, + }, + { + label: t("home.settings.subtitles.modes.None"), + value: SubtitlePlaybackMode.None, + }, + ]; + + // MPV alignment options + const alignXOptions = [ + { label: "Left", value: "left" }, + { label: "Center", value: "center" }, + { label: "Right", value: "right" }, + ]; + + const alignYOptions = [ + { label: "Top", value: "top" }, + { label: "Center", value: "center" }, + { label: "Bottom", value: "bottom" }, + ]; + + return ( + + {/* Header */} + + {t("home.settings.settings_title")} + + + {/* Audio Section */} + + + updateSettings({ audioTranscodeMode: value as AudioTranscodeMode }) + } + isFirst + /> + + {/* Subtitles Section */} + + + updateSettings({ subtitleMode: value as SubtitlePlaybackMode }) + } + /> + + updateSettings({ rememberSubtitleSelections: value }) + } + /> + { + const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1); + updateSettings({ subtitleSize: Math.round(newValue * 100) }); + }} + onIncrease={() => { + const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1); + updateSettings({ subtitleSize: Math.round(newValue * 100) }); + }} + formatValue={(v) => `${v.toFixed(1)}x`} + /> + + {/* MPV Subtitles Section */} + + { + const newValue = Math.max( + 0.5, + (settings.mpvSubtitleScale ?? 1.0) - 0.1, + ); + updateSettings({ mpvSubtitleScale: Math.round(newValue * 10) / 10 }); + }} + onIncrease={() => { + const newValue = Math.min( + 2.0, + (settings.mpvSubtitleScale ?? 1.0) + 0.1, + ); + updateSettings({ mpvSubtitleScale: Math.round(newValue * 10) / 10 }); + }} + formatValue={(v) => `${v.toFixed(1)}x`} + /> + { + const newValue = Math.max(0, (settings.mpvSubtitleMarginY ?? 0) - 5); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 100, + (settings.mpvSubtitleMarginY ?? 0) + 5, + ); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + /> + + updateSettings({ + mpvSubtitleAlignX: value as "left" | "center" | "right", + }) + } + /> + + updateSettings({ + mpvSubtitleAlignY: value as "top" | "center" | "bottom", + }) + } + /> + + {/* Appearance Section */} + + + updateSettings({ mergeNextUpAndContinueWatching: value }) + } + /> + + {/* User Section */} + + + + + {/* Logout Button */} + + + + + ); +} diff --git a/app/(auth)/(tabs)/(settings)/_layout.tsx b/app/(auth)/(tabs)/(settings)/_layout.tsx new file mode 100644 index 000000000..4f1ce0354 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,21 @@ +import { Stack } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Platform } from "react-native"; + +export default function SettingsLayout() { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/app/(auth)/(tabs)/(settings)/index.tsx b/app/(auth)/(tabs)/(settings)/index.tsx new file mode 100644 index 000000000..52b86fb47 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/index.tsx @@ -0,0 +1,5 @@ +import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv"; + +export default function SettingsTabScreen() { + return ; +} From 3e695def23a1dfb6a3a28bbc09847ffb1e845fab Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:57:19 +0100 Subject: [PATCH 012/309] wip --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 162 ++++++++++------------- app/(auth)/player/direct-player.tsx | 82 +++++++----- components/ItemContentSkeleton.tv.tsx | 160 ++++++++++++++++++++++ 3 files changed, 278 insertions(+), 126 deletions(-) create mode 100644 components/ItemContentSkeleton.tv.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 24a086486..518e6ad18 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -274,112 +274,71 @@ const TVSettingsStepper: React.FC<{ ); }; -// TV-optimized dropdown selector -const TVSettingsDropdown: React.FC<{ +// TV-optimized horizontal selector - navigate left/right through options +const TVSettingsSelector: React.FC<{ label: string; options: { label: string; value: string }[]; selectedValue: string; onSelect: (value: string) => void; isFirst?: boolean; }> = ({ label, options, selectedValue, onSelect, isFirst }) => { - const [expanded, setExpanded] = useState(false); - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; + const [rowFocused, setRowFocused] = useState(false); - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); + const currentIndex = options.findIndex((o) => o.value === selectedValue); - const selectedLabel = - options.find((o) => o.value === selectedValue)?.label || selectedValue; - - if (expanded) { - return ( - + + {label} + + - - {label} - {options.map((option, index) => ( - { - onSelect(option.value); - setExpanded(false); - }} - isFirst={index === 0} + onSelect={() => onSelect(option.value)} + onFocus={() => setRowFocused(true)} + onBlur={() => setRowFocused(false)} + isFirst={isFirst && index === currentIndex} /> ))} - ); - } - - return ( - setExpanded(true)} - onFocus={() => { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={isFirst} - > - - {label} - - - {selectedLabel} - - - - - + ); }; -// Dropdown option component -const TVDropdownOption: React.FC<{ +// Individual option button for horizontal selector +const TVSelectorOption: React.FC<{ label: string; selected: boolean; onSelect: () => void; + onFocus: () => void; + onBlur: () => void; isFirst?: boolean; -}> = ({ label, selected, onSelect, isFirst }) => { +}> = ({ label, selected, onSelect, onFocus, onBlur, isFirst }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -396,10 +355,12 @@ const TVDropdownOption: React.FC<{ onPress={onSelect} onFocus={() => { setFocused(true); - animateTo(1.02); + onFocus(); + animateTo(1.08); }} onBlur={() => { setFocused(false); + onBlur(); animateTo(1); }} hasTVPreferredFocus={isFirst} @@ -407,16 +368,27 @@ const TVDropdownOption: React.FC<{ - {label} - {selected && } + + {label} + ); @@ -569,7 +541,7 @@ export default function SettingsTV() { - - - - )} - {isMounted === true && item && !isPipMode && ( - - )} + {isMounted === true && + item && + !isPipMode && + (Platform.isTV ? ( + + ) : ( + + ))} diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx new file mode 100644 index 000000000..2e4a77ea9 --- /dev/null +++ b/components/ItemContentSkeleton.tv.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import { Dimensions, View } from "react-native"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); + +export const ItemContentSkeletonTV: React.FC = () => { + return ( + + {/* Left side - Poster placeholder */} + + + + + {/* Right side - Content placeholders */} + + {/* Logo/Title placeholder */} + + + {/* Metadata badges row */} + + + + + + + {/* Genres placeholder */} + + + + + + + {/* Overview placeholder */} + + + + + + + {/* Play button placeholder */} + + + + ); +}; From 4cdbab7d198335863a516a4cecd17ebad82cb939 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:57:22 +0100 Subject: [PATCH 013/309] wip --- .../items/page.tsx | 50 +-- components/ItemContent.tv.tsx | 33 +- .../video-player/controls/Controls.tv.tsx | 330 ++++++++++++++++++ 3 files changed, 363 insertions(+), 50 deletions(-) create mode 100644 components/video-player/controls/Controls.tv.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx index d61072177..6baf9ca6f 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -3,7 +3,7 @@ import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import Animated, { runOnJS, useAnimatedStyle, @@ -15,6 +15,10 @@ import { ItemContent } from "@/components/ItemContent"; import { useItemQuery } from "@/hooks/useItemQuery"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +const ItemContentSkeletonTV = Platform.isTV + ? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV + : null; + const Page: React.FC = () => { const { id } = useLocalSearchParams() as { id: string }; const { t } = useTranslation(); @@ -81,26 +85,32 @@ const Page: React.FC = () => { - - - - - - - - - - - - - + {Platform.isTV && ItemContentSkeletonTV ? ( + + ) : ( + + + + + + + + + + + + + + + + )} {item && } diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 8f56f26fe..bd075b9c9 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -198,9 +198,7 @@ export const ItemContentTV: React.FC = React.memo( const duration = item.RunTimeTicks ? runtimeTicksToMinutes(item.RunTimeTicks) : null; - const hasProgress = - item.UserData?.PlaybackPositionTicks && - item.UserData.PlaybackPositionTicks > 0; + const hasProgress = (item.UserData?.PlaybackPositionTicks ?? 0) > 0; const remainingTime = hasProgress ? runtimeTicksToMinutes( (item.RunTimeTicks || 0) - @@ -271,7 +269,7 @@ export const ItemContentTV: React.FC = React.memo( = React.memo( : t("common.play")} - - {!isOffline && item.Type !== "Program" && ( - { - // Info/More options action - }} - variant='secondary' - > - - - {t("item_card.more_info")} - - - )} {/* Progress bar (if partially watched) */} - {hasProgress && item.RunTimeTicks && ( + {hasProgress && item.RunTimeTicks != null && ( ; + cacheProgress: SharedValue; + progress: SharedValue; + isBuffering?: boolean; + showControls: boolean; + togglePlay: () => void; + setShowControls: (shown: boolean) => void; + mediaSource?: MediaSourceInfo | null; + seek: (ticks: number) => void; + play: () => void; + pause: () => void; +} + +const TV_SEEKBAR_HEIGHT = 16; +const TV_AUTO_HIDE_TIMEOUT = 5000; + +export const Controls: FC = ({ + item, + seek, + play, + pause, + togglePlay, + isPlaying, + isSeeking, + progress, + cacheProgress, + showControls, + setShowControls, +}) => { + const insets = useSafeAreaInsets(); + + const { + trickPlayUrl, + calculateTrickplayUrl, + trickplayInfo, + prefetchAllTrickplayImages, + } = useTrickplay(item); + + const min = useSharedValue(0); + const maxMs = ticksToMs(item.RunTimeTicks || 0); + const max = useSharedValue(maxMs); + + // Animation values for controls + const controlsOpacity = useSharedValue(showControls ? 1 : 0); + const bottomTranslateY = useSharedValue(showControls ? 0 : 50); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, [prefetchAllTrickplayImages]); + + // Animate controls visibility + useEffect(() => { + const animationConfig = { + duration: 300, + easing: Easing.out(Easing.quad), + }; + + controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig); + bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig); + }, [showControls, controlsOpacity, bottomTranslateY]); + + // Create animated style for bottom controls + const bottomAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + transform: [{ translateY: bottomTranslateY.value }], + })); + + // Initialize progress values + useEffect(() => { + if (item) { + progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); + max.value = ticksToMs(item.RunTimeTicks || 0); + } + }, [item, progress, max]); + + // Time management hook + const { currentTime, remainingTime } = useVideoTime({ + progress, + max, + isSeeking, + }); + + const toggleControls = useCallback(() => { + setShowControls(!showControls); + }, [showControls, setShowControls]); + + // Remote control hook for TV navigation + const { + remoteScrubProgress, + isRemoteScrubbing, + showRemoteBubble, + isSliding: isRemoteSliding, + time: remoteTime, + } = useRemoteControl({ + progress, + min, + max, + showControls, + isPlaying, + seek, + play, + togglePlay, + toggleControls, + calculateTrickplayUrl, + handleSeekForward: () => {}, + handleSeekBackward: () => {}, + }); + + // Slider hook + const { + isSliding, + time, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + } = useVideoSlider({ + progress, + isSeeking, + isPlaying, + seek, + play, + pause, + calculateTrickplayUrl, + showControls, + }); + + const effectiveProgress = useSharedValue(0); + + // Recompute progress for remote scrubbing + useAnimatedReaction( + () => ({ + isScrubbing: isRemoteScrubbing.value, + scrub: remoteScrubProgress.value, + actual: progress.value, + }), + (current, previous) => { + if ( + current.isScrubbing !== previous?.isScrubbing || + current.isScrubbing + ) { + effectiveProgress.value = + current.isScrubbing && current.scrub != null + ? current.scrub + : current.actual; + } else { + const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; + const progressDiff = Math.abs(current.actual - effectiveProgress.value); + if (progressDiff >= progressUnit) { + effectiveProgress.value = current.actual; + } + } + }, + [], + ); + + const hideControls = useCallback(() => { + setShowControls(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isSliding || isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + return ( + + + + {/* Metadata */} + + {item?.Type === "Episode" && ( + + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + + )} + {item?.Name} + {item?.Type === "Movie" && ( + {item?.ProductionYear} + )} + + + {/* Large Seekbar */} + + null} + cache={cacheProgress} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={styles.sliderTrack} + renderBubble={() => + (isSliding || showRemoteBubble) && ( + + ) + } + sliderHeight={TV_SEEKBAR_HEIGHT} + thumbWidth={0} + progress={effectiveProgress} + minimumValue={min} + maximumValue={max} + /> + + + {/* Time Display - TV sized */} + + + {formatTimeString(currentTime, "ms")} + + + -{formatTimeString(remainingTime, "ms")} + + + + + + ); +}; + +const styles = StyleSheet.create({ + controlsContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + bottomContainer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: 10, + }, + bottomInner: { + flexDirection: "column", + }, + metadataContainer: { + marginBottom: 16, + }, + subtitleText: { + color: "rgba(255,255,255,0.6)", + fontSize: 18, + }, + titleText: { + color: "#fff", + fontSize: 28, + fontWeight: "bold", + }, + sliderContainer: { + height: TV_SEEKBAR_HEIGHT, + justifyContent: "center", + alignItems: "stretch", + }, + sliderTrack: { + borderRadius: 100, + }, + timeContainer: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 12, + }, + timeText: { + color: "rgba(255,255,255,0.7)", + fontSize: 22, + }, +}); From fe26a744510746ca3a2a52b8204c9b58a3c99188 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 09:11:27 +0100 Subject: [PATCH 014/309] wip: home page --- components/ContinueWatchingPoster.tv.tsx | 132 ++++ components/home/Home.tsx | 13 +- components/home/Home.tv.tsx | 572 ++++++++++++++++++ .../InfiniteScrollingCollectionList.tv.tsx | 347 +++++++++++ .../StreamystatsPromotedWatchlists.tv.tsx | 327 ++++++++++ .../home/StreamystatsRecommendations.tv.tsx | 262 ++++++++ components/posters/MoviePoster.tv.tsx | 84 +++ components/posters/SeriesPoster.tv.tsx | 71 +++ components/tv/TVFocusablePoster.tsx | 63 ++ .../video-player/controls/Controls.tv.tsx | 28 + 10 files changed, 1898 insertions(+), 1 deletion(-) create mode 100644 components/ContinueWatchingPoster.tv.tsx create mode 100644 components/home/Home.tv.tsx create mode 100644 components/home/InfiniteScrollingCollectionList.tv.tsx create mode 100644 components/home/StreamystatsPromotedWatchlists.tv.tsx create mode 100644 components/home/StreamystatsRecommendations.tv.tsx create mode 100644 components/posters/MoviePoster.tv.tsx create mode 100644 components/posters/SeriesPoster.tv.tsx create mode 100644 components/tv/TVFocusablePoster.tsx diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx new file mode 100644 index 000000000..9dda56945 --- /dev/null +++ b/components/ContinueWatchingPoster.tv.tsx @@ -0,0 +1,132 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtomValue } from "jotai"; +import type React from "react"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { ProgressBar } from "./common/ProgressBar"; +import { WatchedIndicator } from "./WatchedIndicator"; + +export const TV_LANDSCAPE_WIDTH = 340; + +type ContinueWatchingPosterProps = { + item: BaseItemDto; + useEpisodePoster?: boolean; + size?: "small" | "normal"; + showPlayButton?: boolean; +}; + +const ContinueWatchingPoster: React.FC = ({ + item, + useEpisodePoster = false, + // TV version uses fixed width, size prop kept for API compatibility + size: _size = "normal", + showPlayButton = false, +}) => { + const api = useAtomValue(apiAtom); + + const url = useMemo(() => { + if (!api) { + return; + } + if (item.Type === "Episode" && useEpisodePoster) { + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Episode") { + if (item.ParentBackdropItemId && item.ParentThumbImageTag) { + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Movie") { + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Program") { + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + }, [api, item, useEpisodePoster]); + + if (!url) { + return ( + + ); + } + + return ( + + + + {showPlayButton && ( + + + + )} + + {!item.UserData?.Played && } + + + ); +}; + +export default ContinueWatchingPoster; diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 1da3b358e..abd53e0f8 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -44,6 +44,9 @@ import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { storage } from "@/utils/mmkv"; +// Conditionally load TV version +const HomeTV = Platform.isTV ? require("./Home.tv").Home : null; + type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; title?: string; @@ -64,7 +67,7 @@ type MediaListSectionType = { type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; -export const Home = () => { +const HomeMobile = () => { const router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -687,3 +690,11 @@ export const Home = () => { ); }; + +// Exported component that renders TV or mobile version based on platform +export const Home = () => { + if (Platform.isTV && HomeTV) { + return ; + } + return ; +}; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx new file mode 100644 index 000000000..d073976bd --- /dev/null +++ b/components/home/Home.tv.tsx @@ -0,0 +1,572 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + BaseItemDtoQueryResult, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { type QueryFunction, useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; +import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; +import { Loader } from "@/components/Loader"; +import useRouter from "@/hooks/useAppRouter"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; +// Reduced gap since sections have internal padding for scale animations +const SECTION_GAP = 10; + +type InfiniteScrollingCollectionListSection = { + type: "InfiniteScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; + pageSize?: number; + priority?: 1 | 2; + parentId?: string; +}; + +type Section = InfiniteScrollingCollectionListSection; + +export const Home = () => { + const _router = useRouter(); + const { t } = useTranslation(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const insets = useSafeAreaInsets(); + const { settings } = useSettings(); + const scrollRef = useRef(null); + const { + isConnected, + serverConnected, + loading: retryLoading, + retryCheck, + } = useNetworkStatus(); + const _invalidateCache = useInvalidatePlaybackProgressCache(); + const [loadedSections, setLoadedSections] = useState>(new Set()); + + const { + data, + isError: e1, + isLoading: l1, + } = useQuery({ + queryKey: ["home", "userViews", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id, + }); + + return response.data.Items || null; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + }); + + const userViews = useMemo( + () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), + [data, settings?.hiddenLibraries], + ); + + const collections = useMemo(() => { + const allow = ["movies", "tvshows"]; + return ( + userViews?.filter( + (c) => c.CollectionType && allow.includes(c.CollectionType), + ) || [] + ); + }, [userViews]); + + const createCollectionConfig = useCallback( + ( + title: string, + queryKey: string[], + includeItemTypes: BaseItemKind[], + parentId: string | undefined, + pageSize = 10, + ): InfiniteScrollingCollectionListSection => ({ + title, + queryKey, + queryFn: async ({ pageParam = 0 }) => { + if (!api) return []; + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 10, + fields: ["PrimaryImageAspectRatio"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + }, + type: "InfiniteScrollingCollectionList", + pageSize, + parentId, + }), + [api, user?.Id], + ); + + const defaultSections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" || c.CollectionType === "movies" + ? [] + : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey: string[] = [ + "home", + `recentlyAddedIn${c.CollectionType}`, + user.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id, + 10, + ); + }); + + const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { + return items.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + }; + + const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { + const seen = new Set(); + return items.filter((item) => { + if (!item.Id || seen.has(item.Id)) return false; + seen.add(item.Id); + return true; + }); + }; + + const firstSections: Section[] = settings.mergeNextUpAndContinueWatching + ? [ + { + title: t("home.continue_and_next_up"), + queryKey: ["home", "continueAndNextUp"], + queryFn: async ({ pageParam = 0 }) => { + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: 0, + limit: 20, + }), + getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: 0, + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + const combined = [...resumeItems, ...nextUpItems]; + const sorted = sortByRecentActivity(combined); + const deduplicated = deduplicateById(sorted); + + return deduplicated.slice(pageParam, pageParam + 10); + }, + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ] + : [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async ({ pageParam = 0 }) => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: pageParam, + limit: 10, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async ({ pageParam = 0 }) => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ]; + + const ss: Section[] = [ + ...firstSections, + ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })), + ...(!settings?.streamyStatsMovieRecommendations + ? [ + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList" as const, + orientation: "vertical" as const, + pageSize: 10, + priority: 2 as const, + }, + ] + : []), + ]; + return ss; + }, [ + api, + user?.Id, + collections, + t, + createCollectionConfig, + settings?.streamyStatsMovieRecommendations, + settings.mergeNextUpAndContinueWatching, + ]); + + const customSections = useMemo(() => { + if (!api || !user?.Id || !settings?.home?.sections) return []; + const ss: Section[] = []; + settings.home.sections.forEach((section, index) => { + const id = section.title || `section-${index}`; + const pageSize = 10; + ss.push({ + title: t(`${id}`), + queryKey: ["home", "custom", String(index), section.title ?? null], + queryFn: async ({ pageParam = 0 }) => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + startIndex: pageParam, + limit: section.items?.limit || pageSize, + recursive: true, + includeItemTypes: section.items?.includeItemTypes, + sortBy: section.items?.sortBy, + sortOrder: section.items?.sortOrder, + filters: section.items?.filters, + parentId: section.items?.parentId, + }); + return response.data.Items || []; + } + if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: section.nextUp?.limit || pageSize, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.nextUp?.enableResumable, + enableRewatching: section.nextUp?.enableRewatching, + }); + return response.data.Items || []; + } + if (section.latest) { + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 10, + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + } + if (section.custom) { + const response = await api.get( + section.custom.endpoint, + { + params: { + ...(section.custom.query || {}), + userId: user?.Id, + startIndex: pageParam, + limit: pageSize, + }, + headers: section.custom.headers || {}, + }, + ); + return response.data.Items || []; + } + return []; + }, + type: "InfiniteScrollingCollectionList", + orientation: section?.orientation || "vertical", + pageSize, + priority: index < 2 ? 1 : 2, + }); + }); + return ss; + }, [api, user?.Id, settings?.home?.sections, t]); + + const sections = settings?.home?.sections ? customSections : defaultSections; + + const highPrioritySectionKeys = useMemo(() => { + return sections + .filter((s) => s.priority === 1) + .map((s) => s.queryKey.join("-")); + }, [sections]); + + const allHighPriorityLoaded = useMemo(() => { + return highPrioritySectionKeys.every((key) => loadedSections.has(key)); + }, [highPrioritySectionKeys, loadedSections]); + + const markSectionLoaded = useCallback( + (queryKey: (string | undefined | null)[]) => { + const key = queryKey.join("-"); + setLoadedSections((prev) => new Set(prev).add(key)); + }, + [], + ); + + if (!isConnected || serverConnected !== true) { + let title = ""; + let subtitle = ""; + + if (!isConnected) { + title = t("home.no_internet"); + subtitle = t("home.no_internet_message"); + } else if (serverConnected === null) { + title = t("home.checking_server_connection"); + subtitle = t("home.checking_server_connection_message"); + } else if (!serverConnected) { + title = t("home.server_unreachable"); + subtitle = t("home.server_unreachable_message"); + } + return ( + + + {title} + + + {subtitle} + + + + + + + ); + } + + if (e1) + return ( + + + {t("home.oops")} + + + {t("home.error_message")} + + + ); + + if (l1) + return ( + + + + ); + + return ( + + + {sections.map((section, index) => { + // Render Streamystats sections after Continue Watching and Next Up + // When merged, they appear after index 0; otherwise after index 1 + const streamystatsIndex = settings.mergeNextUpAndContinueWatching + ? 0 + : 1; + const hasStreamystatsContent = + settings.streamyStatsMovieRecommendations || + settings.streamyStatsSeriesRecommendations || + settings.streamyStatsPromotedWatchlists; + const streamystatsSections = + index === streamystatsIndex && hasStreamystatsContent ? ( + + {settings.streamyStatsMovieRecommendations && ( + + )} + {settings.streamyStatsSeriesRecommendations && ( + + )} + {settings.streamyStatsPromotedWatchlists && ( + + )} + + ) : null; + + if (section.type === "InfiniteScrollingCollectionList") { + const isHighPriority = section.priority === 1; + const isFirstSection = index === 0; + return ( + + markSectionLoaded(section.queryKey) + : undefined + } + isFirstSection={isFirstSection} + /> + {streamystatsSections} + + ); + } + return null; + })} + + + ); +}; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx new file mode 100644 index 000000000..ee834e7b6 --- /dev/null +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -0,0 +1,347 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + type QueryFunction, + type QueryKey, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + FlatList, + View, + type ViewProps, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; +import ContinueWatchingPoster, { + TV_LANDSCAPE_WIDTH, +} from "../ContinueWatchingPoster.tv"; +import SeriesPoster from "../posters/SeriesPoster.tv"; + +const ITEM_GAP = 16; +// Extra padding to accommodate scale animation (1.05x) and glow shadow +const SCALE_PADDING = 20; + +interface Props extends ViewProps { + title?: string | null; + orientation?: "horizontal" | "vertical"; + disabled?: boolean; + queryKey: QueryKey; + queryFn: QueryFunction; + hideIfEmpty?: boolean; + pageSize?: number; + onPressSeeAll?: () => void; + enabled?: boolean; + onLoaded?: () => void; + isFirstSection?: boolean; +} + +// TV-specific ItemCardText with larger fonts +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + {item.Type === "Episode" ? ( + <> + + {item.Name} + + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + {" - "} + {item.SeriesName} + + + ) : ( + <> + + {item.Name} + + + {item.ProductionYear} + + + )} + + ); +}; + +export const InfiniteScrollingCollectionList: React.FC = ({ + title, + orientation = "vertical", + disabled = false, + queryFn, + queryKey, + hideIfEmpty = false, + pageSize = 10, + enabled = true, + onLoaded, + isFirstSection = false, + ...props +}) => { + const effectivePageSize = Math.max(1, pageSize); + const hasCalledOnLoaded = useRef(false); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isSuccess, + } = useInfiniteQuery({ + queryKey: queryKey, + queryFn: ({ pageParam = 0, ...context }) => + queryFn({ ...context, queryKey, pageParam }), + getNextPageParam: (lastPage, allPages) => { + if (lastPage.length < effectivePageSize) { + return undefined; + } + return allPages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + staleTime: 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + enabled, + }); + + useEffect(() => { + if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { + hasCalledOnLoaded.current = true; + onLoaded(); + } + }, [isSuccess, onLoaded]); + + const { t } = useTranslation(); + + const allItems = useMemo(() => { + const items = data?.pages.flat() ?? []; + const seen = new Set(); + const deduped: BaseItemDto[] = []; + + for (const item of items) { + const id = item.Id; + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + deduped.push(item); + } + + return deduped; + }, [data]); + + const itemWidth = + orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: itemWidth + ITEM_GAP, + offset: (itemWidth + ITEM_GAP) * index, + index, + }), + [itemWidth], + ); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => { + const isFirstItem = isFirstSection && index === 0; + const isHorizontal = orientation === "horizontal"; + + const renderPoster = () => { + if (item.Type === "Episode" && isHorizontal) { + return ; + } + if (item.Type === "Episode" && !isHorizontal) { + return ; + } + if (item.Type === "Movie" && isHorizontal) { + return ; + } + if (item.Type === "Movie" && !isHorizontal) { + return ; + } + if (item.Type === "Series" && !isHorizontal) { + return ; + } + if (item.Type === "Series" && isHorizontal) { + return ; + } + if (item.Type === "Program") { + return ; + } + if (item.Type === "BoxSet" && !isHorizontal) { + return ; + } + if (item.Type === "BoxSet" && isHorizontal) { + return ; + } + if (item.Type === "Playlist" && !isHorizontal) { + return ; + } + if (item.Type === "Playlist" && isHorizontal) { + return ; + } + if (item.Type === "Video" && !isHorizontal) { + return ; + } + if (item.Type === "Video" && isHorizontal) { + return ; + } + // Default fallback + return isHorizontal ? ( + + ) : ( + + ); + }; + + return ( + + handleItemPress(item)} + hasTVPreferredFocus={isFirstItem} + > + {renderPoster()} + + + + ); + }, + [orientation, isFirstSection, itemWidth, handleItemPress], + ); + + if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; + if (disabled || !title) return null; + + return ( + + {/* Section Header */} + + {title} + + + {isLoading === false && allItems.length === 0 && ( + + {t("home.no_items")} + + )} + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + /> + )} + + ); +}; diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx new file mode 100644 index 000000000..45521979c --- /dev/null +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -0,0 +1,327 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; + +const ITEM_GAP = 16; +const SCALE_PADDING = 20; + +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; + +interface WatchlistSectionProps extends ViewProps { + watchlist: StreamystatsWatchlist; + jellyfinServerId: string; +} + +const WatchlistSection: React.FC = ({ + watchlist, + jellyfinServerId, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const { data: items, isLoading } = useQuery({ + queryKey: [ + "streamystats", + "watchlist", + watchlist.id, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const watchlistDetail = await streamystatsApi.getWatchlistItemIds({ + watchlistId: watchlist.id, + jellyfinServerId, + }); + + const itemIds = watchlistDetail.data?.items; + if (!itemIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: itemIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(settings?.streamyStatsServerUrl) && + Boolean(api?.accessToken) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: TV_POSTER_WIDTH + ITEM_GAP, + offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + index, + }), + [], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + hasTVPreferredFocus={false} + > + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + + ); + }, + [handleItemPress], + ); + + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {watchlist.name} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + )} + + ); +}; + +interface StreamystatsPromotedWatchlistsProps extends ViewProps { + enabled?: boolean; +} + +export const StreamystatsPromotedWatchlists: React.FC< + StreamystatsPromotedWatchlistsProps +> = ({ enabled = true, ...props }) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: watchlists, + isLoading, + isError, + } = useQuery({ + queryKey: [ + "streamystats", + "promotedWatchlists", + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getPromotedWatchlists({ + jellyfinServerId, + includePreview: false, + }); + + return response.data || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!watchlists || watchlists.length === 0)) return null; + + if (isLoading) { + return ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + + ); + } + + return ( + <> + {watchlists?.map((watchlist) => ( + + ))} + + ); +}; diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx new file mode 100644 index 000000000..c163a94ec --- /dev/null +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -0,0 +1,262 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; + +const ITEM_GAP = 16; +const SCALE_PADDING = 20; + +interface Props extends ViewProps { + title: string; + type: "Movie" | "Series"; + limit?: number; + enabled?: boolean; +} + +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; + +export const StreamystatsRecommendations: React.FC = ({ + title, + type, + limit = 20, + enabled = true, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: recommendationIds, + isLoading: isLoadingRecommendations, + isError: isRecommendationsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + type, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamyStatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamyStatsApi.getRecommendationIds( + jellyfinServerId, + type, + limit, + ); + + const data = response as StreamystatsRecommendationsIdsResponse; + + if (type === "Movie") { + return data.data.movies || []; + } + return data.data.series || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const { + data: items, + isLoading: isLoadingItems, + isError: isItemsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + "items", + type, + recommendationIds, + ], + queryFn: async (): Promise => { + if (!api || !user?.Id || !recommendationIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: recommendationIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const isLoading = isLoadingRecommendations || isLoadingItems; + const isError = isRecommendationsError || isItemsError; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: TV_POSTER_WIDTH + ITEM_GAP, + offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + index, + }), + [], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + hasTVPreferredFocus={false} + > + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + + ); + }, + [handleItemPress], + ); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {title} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + )} + + ); +}; diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx new file mode 100644 index 000000000..c4d9daee6 --- /dev/null +++ b/components/posters/MoviePoster.tv.tsx @@ -0,0 +1,84 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export const TV_POSTER_WIDTH = 210; + +type MoviePosterProps = { + item: BaseItemDto; + showProgress?: boolean; +}; + +const MoviePoster: React.FC = ({ + item, + showProgress = false, +}) => { + const [api] = useAtom(apiAtom); + + const url = useMemo(() => { + return getPrimaryImageUrl({ + api, + item, + width: 420, // 2x for quality on large screens + }); + }, [api, item]); + + const progress = item.UserData?.PlayedPercentage || 0; + + const blurhash = useMemo(() => { + const key = item.ImageTags?.Primary as string; + return item.ImageBlurHashes?.Primary?.[key]; + }, [item]); + + return ( + + + + {showProgress && progress > 0 && ( + + )} + + ); +}; + +export default MoviePoster; diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx new file mode 100644 index 000000000..5d4bb97a3 --- /dev/null +++ b/components/posters/SeriesPoster.tv.tsx @@ -0,0 +1,71 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export const TV_POSTER_WIDTH = 210; + +type SeriesPosterProps = { + item: BaseItemDto; + showProgress?: boolean; +}; + +const SeriesPoster: React.FC = ({ item }) => { + const [api] = useAtom(apiAtom); + + const url = useMemo(() => { + if (item.Type === "Episode") { + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`; + } + return getPrimaryImageUrl({ + api, + item, + width: 420, // 2x for quality on large screens + }); + }, [api, item]); + + const blurhash = useMemo(() => { + const key = item.ImageTags?.Primary as string; + return item.ImageBlurHashes?.Primary?.[key]; + }, [item]); + + return ( + + + + ); +}; + +export default SeriesPoster; diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx new file mode 100644 index 000000000..9748ed415 --- /dev/null +++ b/components/tv/TVFocusablePoster.tsx @@ -0,0 +1,63 @@ +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, type ViewStyle } from "react-native"; + +interface TVFocusablePosterProps { + children: React.ReactNode; + onPress: () => void; + hasTVPreferredFocus?: boolean; + glowColor?: "white" | "purple"; + scaleAmount?: number; + style?: ViewStyle; +} + +export const TVFocusablePoster: React.FC = ({ + children, + onPress, + hasTVPreferredFocus = false, + glowColor = "white", + scaleAmount = 1.05, + style, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7"; + + return ( + { + setFocused(true); + animateTo(scaleAmount); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {children} + + + ); +}; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index c07388cd0..d1d47604c 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, @@ -199,6 +200,15 @@ export const Controls: FC = ({ return ( + {/* Center Play Button - shown when paused */} + {!isPlaying && showControls && ( + + + + + + )} + Date: Fri, 16 Jan 2026 10:06:41 +0100 Subject: [PATCH 015/309] wip --- app.json | 4 +++ components/ItemContentSkeleton.tv.tsx | 4 +-- .../InfiniteScrollingCollectionList.tv.tsx | 35 +++++++++++++++++-- components/tv/TVFocusablePoster.tsx | 6 ++++ eas.json | 17 ++++++++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/app.json b/app.json index 288f3e3af..38b77a23f 100644 --- a/app.json +++ b/app.json @@ -36,6 +36,10 @@ "icon": "./assets/images/icon-ios-liquid-glass.icon", "appleTeamId": "MWD5K362T8" }, + "tvos": { + "icon": "./assets/images/icon.png", + "bundleIdentifier": "com.fredrikburmester.streamyfin" + }, "android": { "jsEngine": "hermes", "versionCode": 92, diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx index 2e4a77ea9..6b1069378 100644 --- a/components/ItemContentSkeleton.tv.tsx +++ b/components/ItemContentSkeleton.tv.tsx @@ -9,8 +9,8 @@ export const ItemContentSkeletonTV: React.FC = () => { style={{ flex: 1, flexDirection: "row", - paddingTop: 140, - paddingHorizontal: 80, + paddingTop: 180, + paddingHorizontal: 160, }} > {/* Left side - Poster placeholder */} diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ee834e7b6..b09a72f74 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -5,7 +5,7 @@ import { useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -95,6 +95,27 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; + // Track focus within section and scroll back to start when leaving + const flatListRef = useRef>(null); + const [focusedCount, setFocusedCount] = useState(0); + const prevFocusedCount = useRef(0); + + // When section loses all focus, scroll back to start + useEffect(() => { + if (prevFocusedCount.current > 0 && focusedCount === 0) { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + } + prevFocusedCount.current = focusedCount; + }, [focusedCount]); + + const handleItemFocus = useCallback(() => { + setFocusedCount((c) => c + 1); + }, []); + + const handleItemBlur = useCallback(() => { + setFocusedCount((c) => Math.max(0, c - 1)); + }, []); + const { data, isLoading, @@ -229,6 +250,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress(item)} hasTVPreferredFocus={isFirstItem} + onFocus={handleItemFocus} + onBlur={handleItemBlur} > {renderPoster()} @@ -236,7 +259,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ ); }, - [orientation, isFirstSection, itemWidth, handleItemPress], + [ + orientation, + isFirstSection, + itemWidth, + handleItemPress, + handleItemFocus, + handleItemBlur, + ], ); if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; @@ -310,6 +340,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ ) : ( item.Id!} diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx index 9748ed415..fc89b70fe 100644 --- a/components/tv/TVFocusablePoster.tsx +++ b/components/tv/TVFocusablePoster.tsx @@ -8,6 +8,8 @@ interface TVFocusablePosterProps { glowColor?: "white" | "purple"; scaleAmount?: number; style?: ViewStyle; + onFocus?: () => void; + onBlur?: () => void; } export const TVFocusablePoster: React.FC = ({ @@ -17,6 +19,8 @@ export const TVFocusablePoster: React.FC = ({ glowColor = "white", scaleAmount = 1.05, style, + onFocus: onFocusProp, + onBlur: onBlurProp, }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -37,10 +41,12 @@ export const TVFocusablePoster: React.FC = ({ onFocus={() => { setFocused(true); animateTo(scaleAmount); + onFocusProp?.(); }} onBlur={() => { setFocused(false); animateTo(1); + onBlurProp?.(); }} hasTVPreferredFocus={hasTVPreferredFocus} > diff --git a/eas.json b/eas.json index 8a9736d31..5ecd93c37 100644 --- a/eas.json +++ b/eas.json @@ -43,6 +43,13 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, + "preview_tv": { + "distribution": "internal", + "env": { + "EXPO_TV": "1", + "EXPO_PUBLIC_WRITE_DEBUG": "1" + } + }, "production": { "environment": "production", "channel": "0.52.0", @@ -68,9 +75,17 @@ "env": { "EXPO_TV": "1" } + }, + "production_tv": { + "environment": "production", + "channel": "0.52.0", + "env": { + "EXPO_TV": "1" + } } }, "submit": { - "production": {} + "production": {}, + "production_tv": {} } } From e10a99cc48a415cb13ec03f37d4083e37c94f747 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 10:47:48 +0100 Subject: [PATCH 016/309] wip: build for tv --- app.json | 20 +++++++++--- assets/images/icon-tvos-small-2x.png | Bin 0 -> 37371 bytes assets/images/icon-tvos-small.png | Bin 0 -> 15016 bytes assets/images/icon-tvos-topshelf-2x.png | Bin 0 -> 197657 bytes assets/images/icon-tvos-topshelf-wide-2x.png | Bin 0 -> 227284 bytes assets/images/icon-tvos-topshelf-wide.png | Bin 0 -> 94185 bytes assets/images/icon-tvos-topshelf.png | Bin 0 -> 83035 bytes assets/images/icon-tvos.png | Bin 0 -> 71426 bytes components/ItemContent.tv.tsx | 4 +++ plugins/withTVOSAppIcon.js | 31 +++++++++++++++++++ 10 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 assets/images/icon-tvos-small-2x.png create mode 100644 assets/images/icon-tvos-small.png create mode 100644 assets/images/icon-tvos-topshelf-2x.png create mode 100644 assets/images/icon-tvos-topshelf-wide-2x.png create mode 100644 assets/images/icon-tvos-topshelf-wide.png create mode 100644 assets/images/icon-tvos-topshelf.png create mode 100644 assets/images/icon-tvos.png create mode 100644 plugins/withTVOSAppIcon.js diff --git a/app.json b/app.json index 38b77a23f..a95ce470d 100644 --- a/app.json +++ b/app.json @@ -36,10 +36,6 @@ "icon": "./assets/images/icon-ios-liquid-glass.icon", "appleTeamId": "MWD5K362T8" }, - "tvos": { - "icon": "./assets/images/icon.png", - "bundleIdentifier": "com.fredrikburmester.streamyfin" - }, "android": { "jsEngine": "hermes", "versionCode": 92, @@ -59,7 +55,20 @@ "googleServicesFile": "./google-services.json" }, "plugins": [ - "@react-native-tvos/config-tv", + [ + "@react-native-tvos/config-tv", + { + "appleTVImages": { + "icon": "./assets/images/icon-tvos.png", + "iconSmall": "./assets/images/icon-tvos-small.png", + "iconSmall2x": "./assets/images/icon-tvos-small-2x.png", + "topShelf": "./assets/images/icon-tvos-topshelf.png", + "topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png", + "topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png", + "topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png" + } + } + ], "expo-router", "expo-font", "./plugins/withExcludeMedia3Dash.js", @@ -125,6 +134,7 @@ ["./plugins/withAndroidManifest.js"], ["./plugins/withTrustLocalCerts.js"], ["./plugins/withGradleProperties.js"], + ["./plugins/withTVOSAppIcon.js"], [ "./plugins/withGitPod.js", { diff --git a/assets/images/icon-tvos-small-2x.png b/assets/images/icon-tvos-small-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..497cfd4b87e34b83f5a8886476d35c837d31ac17 GIT binary patch literal 37371 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 u2TyBQN$SZ;Z+DQ4lnMVo!T;+vd?iJjVUPr literal 0 HcmV?d00001 diff --git a/assets/images/icon-tvos-topshelf-2x.png b/assets/images/icon-tvos-topshelf-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..15f077e3a3ef64da95b881f00ca155db9d5ce6c4 GIT binary patch literal 197657 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 DmZ~Fx literal 0 HcmV?d00001 diff --git a/assets/images/icon-tvos-topshelf-wide-2x.png b/assets/images/icon-tvos-topshelf-wide-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..69cdddf5b972f4fdb225da2615ab4ee6aaf5a0df GIT binary patch literal 227284 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/images/icon-tvos-topshelf-wide.png b/assets/images/icon-tvos-topshelf-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..6758a54403ad0f087e5d6cb2fa4632f44f71dd4b GIT binary patch literal 94185 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

    yv^cFf3)Uw*D<2h(kAy5Pdq*v=1=zcp_xP0Mi zPCEa2CwPA)dCqJwHz5S^6;1B+;@>?TW<|L68v3tV&p+KWC3Bu=YiMw$0`lwB{Gik| zr(8y*J-RX7W-3q4FSht{_UkipjmnPij2sPb;v7iuU@I=1iVFW%`4tuE0YMXX_fmWM z1S-U`V+FqQ8T*(9$!`cx^Mhr5#a<2>D(Ozwbs>(N-fHDnLk6JPDFhM zKAJO8&tP7>uGfd(3nR4Us(zy$DYmW+oA+pbv7gsJ*TW-FJ7_9!yiYg+1>A_9b zLutk>zIwjhANVaMt1gJ!W&A!BoM^vYP&;?wPtiN!CXix>x#r~)pqz~1U7;8!(M?@) z1N+?qpza?gln_4CH{NI0xqZC2#nf+h^co3IXgPl*d2GOFua_zpvhNiHU>cwq!QFB@ zmlT9I(8njDfb@#7{Z+q~9@#(0?Gpp-X>ynkNd6uzX3M>H6v$DI%E)?lz=o(L-khIH zwYppG+!GMIMe6WSTK5l02yBM$fA2j`!3#8oq+0!gG(i|f3Sgil#I(lEHUiMckybs{ z<2vM+U`1Db(#dA5Q%GrDFo^Qo;hB-5@(2p4dBQysYEqz>Z?@3a9y zcRkcwmPt^iaE9d#70*>*m*2)2KD+PgAiN2sO)5Vzb`XpWMzA?k=D$7Y9Lj0_5!fKe z1Jo@E3W)CIE*uDh`MYDv_&7{7QQW{+b-x0gnB>Yet!=ym_G{PGfV#@3+vpIBPHdZ8 z??&ZCqvioM;uN5Kpf}b24d`BYW=a#)1Xck2T^T4^JIH?Ztx{tpiJ!|r9rD`gfqrM< zXO&y#Iu?~8Bw4w5g zHYer;6bJ!&q@Qa(udIX50M|AG3oTF`Qa^79#NSD6HSu=CufLFU`u+0$j7K+4*9O?y zNETkWTl-4)B{aucqaHHqTKa6yxy zeKe{tL>q@LKe&wG#9@uQP;dmMLXuJn_hY`RpEi-C8-=%aXYLg({~GF4KgmAYD_rd3 zQa=MT^0+g~6Yy?!paQxS38uI6ou>oy`3d*=N3=KddImoS_^OK^cAxlX;%UJp^{zgh zXL{26O*qv3l#3C9(zfYnp%>l8$o07L+jU|+SNqbuRk0w3x<%3$f(s%7wWnz`fI~%U zh;IQB4d9#dX*C91>{G~8ILcI&1NT8yF=c+N4CJwG+Bapx8!}CipOMO?xdH0~Yo&>8 zcdU>PfHa}m>Io|Oo5mKCtYjZ`8&CdV3(IZfA51pdvL}G=b)3*>Hb;-f$99&vf+~m# zcA60a(iipfnpx2H4Uzth<);%g-|`3_^o?Xfy}=xAs;0Gc7vs4IbgY2eVI^R{7_NE- z{oJ4^zk!j35rp0l(7#Mdc*w#J4;hY#o_lM|5bgZb-HYbR3E0d%nvLI8*7T&~cMXmn zq`2cn!i&Ypgt21x1dv5iYp*aTE<4&yi5Yl1;uni+1k^cGMefGxsQ$24KVyuYB-_Y# zBhuBS2Uc7Ma6x6_--;0w%dy9gd|5Y%%bnZ2gMpIze@dk2Rbm1bJJsbN&pohRvy?IOC#YFuYQC1*}m$|n@Gm5P=(~g6RcALmj_D)}hZ1^^QA@5iE9EQc_o!M)QX|=8Y!MXjc?zk=n@SuC;cM~L2YgwfR)!zG`|r2(O{aE z+}Y+7dV^JY7zYTK8`aT`M70aHx^dYd%sted{xeYIFiFMvi|;RB2`8^xB@&>*y=A^itlrddmA#&caV~hNiynOGs?nx73%x(xvWHGBW- {groups.flatMap((group, groupIndex) => { // Check if this group has radio options From b588195456dc431775ba9b449d5ef7a6d21e0157 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 11:56:49 +0200 Subject: [PATCH 289/309] fix(search): correct Library/Discover tab layout for @expo/ui SDK 55 Native Button no longer renders RN 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. --- components/search/SearchTabButtons.tsx | 40 +++++++------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/components/search/SearchTabButtons.tsx b/components/search/SearchTabButtons.tsx index 55510e52e..600de2ac8 100644 --- a/components/search/SearchTabButtons.tsx +++ b/components/search/SearchTabButtons.tsx @@ -1,7 +1,6 @@ -import { Button, Host } from "@expo/ui/swift-ui"; +import { Button, Host, HStack, Spacer } 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"; type SearchType = "Library" | "Discover"; @@ -19,16 +18,8 @@ export const SearchTabButtons: React.FC = ({ }) => { if (Platform.OS === "ios") { return ( - <> - + + - - + label={t("search.library")} + /> - - + label={t("search.discover")} + /> + + + ); } From dd3ca371089cd369b8ade6511ce9bd35aa6354a8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 12:00:45 +0200 Subject: [PATCH 290/309] refactor(settings): convert login-tv card to ListGroup/ListItem --- app/(auth)/(tabs)/(home)/settings.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 4c38659eb..db223b2bf 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -59,17 +59,17 @@ function SettingsMobile() { - router.push("/(auth)/(tabs)/(home)/companion-login")} - > - - {t("pairing.pair_with_phone_title")} - - - {t("pairing.pair_with_phone_description")} - - + + + + router.push("/(auth)/(tabs)/(home)/companion-login") + } + title={t("pairing.pair_with_phone")} + textColor='blue' + /> + + From d11fb3d0c0e9e5921f95c1cc61fd1804744b3335 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 12:24:39 +0200 Subject: [PATCH 291/309] 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. --- components/PlatformDropdown.tsx | 63 +++++++++---------- components/search/DiscoverFilters.tsx | 88 ++++++++++++++------------- 2 files changed, 75 insertions(+), 76 deletions(-) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index d7d02d274..8f81e567e 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,11 +1,5 @@ -import { - Button, - Host, - Menu, - Picker, - Text as SwiftUIText, -} from "@expo/ui/swift-ui"; -import { disabled, tag } from "@expo/ui/swift-ui/modifiers"; +import { Button, Host, Menu } from "@expo/ui/swift-ui"; +import { disabled } from "@expo/ui/swift-ui/modifiers"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect, useState } from "react"; @@ -299,41 +293,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( - = 0 ? selectedRadioIndex : undefined - } - onSelectionChange={(index) => { - const selectedOption = radioOptions[index as number]; - selectedOption?.onPress(); - onOptionSelect?.(selectedOption?.value); - }} - > - {radioOptions.map((opt, optionIndex) => ( - + {radioOptions.map((option, optionIndex) => ( + , ); } else { // Render radio options as direct buttons diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 443d1e148..59fd51c94 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -1,11 +1,5 @@ -import { - Button, - ContextMenu, - Host, - Picker, - Text as SwiftUIText, -} from "@expo/ui/swift-ui"; -import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers"; +import { Button, Host, Menu } from "@expo/ui/swift-ui"; +import { buttonStyle } from "@expo/ui/swift-ui/modifiers"; import { Platform, View } from "react-native"; import { FilterButton } from "@/components/filters/FilterButton"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; @@ -47,42 +41,54 @@ export const DiscoverFilters: React.FC = ({ marginLeft: "auto", }} > - - + - - - { - setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort); - }} - > - {sortOptions.map((item) => ( - - {t(`home.settings.plugins.jellyseerr.order_by.${item}`)} - - ))} - - { - setJellyseerrSortOrder(value as "asc" | "desc"); - }} - > - {orderOptions.map((item) => ( - - {t(`library.filters.${item}`)} - - ))} - - - + /> + } + > + + {sortOptions.map((item) => { + const isSelected = + jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort); + return ( + + + {orderOptions.map((item) => { + const isSelected = jellyseerrSortOrder === item; + return ( + + ); } From f9b71ef648b68efd6b53fba12bb1d7cc88461fdb Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 12:25:34 +0200 Subject: [PATCH 292/309] style(login): center server-connect layout and adjust logo position --- components/login/Login.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/login/Login.tsx b/components/login/Login.tsx index 6eef3252e..7cf5f43fc 100644 --- a/components/login/Login.tsx +++ b/components/login/Login.tsx @@ -382,16 +382,18 @@ export const Login: React.FC = () => { + + ) : ( - - + + @@ -429,8 +431,6 @@ export const Login: React.FC = () => { await handleConnect(server.address); }} /> - - { await handleConnect(s.address); From 2166bb386726e3ce6492f72be923e50b36f014ff Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 13:05:43 +0200 Subject: [PATCH 293/309] 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. --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 5 ++ components/home/Home.tsx | 5 ++ components/home/Home.tv.tsx | 5 ++ hooks/useRefreshLibraryOnFocus.ts | 50 ++++++++++++++++ providers/WebSocketProvider.tsx | 58 ++++++++++++++++++- 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 hooks/useRefreshLibraryOnFocus.ts diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index ccf38d3ea..9a2239f64 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -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(); diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 74e7c8b09..637e20418 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -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>(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"); diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index c1994c7ef..40131767c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -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(null); const debounceTimerRef = useRef | null>(null); diff --git a/hooks/useRefreshLibraryOnFocus.ts b/hooks/useRefreshLibraryOnFocus.ts new file mode 100644 index 000000000..f89ebd58c --- /dev/null +++ b/hooks/useRefreshLibraryOnFocus.ts @@ -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]), + ); +} diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 41af25cf6..ed9db7549 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -12,9 +12,22 @@ import { } from "react"; import { AppState, type AppStateStatus } from "react-native"; import useRouter from "@/hooks/useAppRouter"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; +// Query keys that depend on the set of library items and should be refreshed +// when the server reports that the library changed (items added/removed/updated). +const LIBRARY_CHANGE_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], + ["seasons"], + ["episodes"], +] as const; + interface WebSocketMessage { MessageType: string; Data: any; @@ -42,10 +55,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const [isConnected, setIsConnected] = useState(false); const [lastMessage, setLastMessage] = useState(null); const router = useRouter(); + const queryClient = useNetworkAwareQueryClient(); const deviceId = useMemo(() => { return getOrSetDeviceId(); }, []); const reconnectAttemptsRef = useRef(0); + const libraryChangeDebounceRef = useRef | null>( + null, + ); const connectWebSocket = useCallback(() => { if (!deviceId || !api?.accessToken || !isNetworkConnected) { @@ -111,14 +128,53 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }; }, [api, deviceId, isNetworkConnected]); + const handleLibraryChanged = useCallback( + (data: any) => { + // Jellyfin sends LibraryChanged when a scan adds/updates/removes items. + // Only refresh when something actually changed in the item set. + const hasChanges = + (data?.ItemsAdded?.length ?? 0) > 0 || + (data?.ItemsRemoved?.length ?? 0) > 0 || + (data?.ItemsUpdated?.length ?? 0) > 0 || + (data?.FoldersAddedTo?.length ?? 0) > 0 || + (data?.FoldersRemovedFrom?.length ?? 0) > 0; + + if (!hasChanges) { + return; + } + + // A single scan can emit several LibraryChanged messages in quick + // succession, so debounce the invalidation to refetch only once. + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + libraryChangeDebounceRef.current = setTimeout(() => { + for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + }, 1000); + }, + [queryClient], + ); + useEffect(() => { if (!lastMessage) { return; } if (lastMessage.MessageType === "Play") { handlePlayCommand(lastMessage.Data); + } else if (lastMessage.MessageType === "LibraryChanged") { + handleLibraryChanged(lastMessage.Data); } - }, [lastMessage, router]); + }, [lastMessage, router, handleLibraryChanged]); + + useEffect(() => { + return () => { + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + }; + }, []); const handlePlayCommand = useCallback( (data: any) => { From c93132177c0305ce146e640342cf47aa56de9174 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 13:29:56 +0200 Subject: [PATCH 294/309] 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. --- components/common/Input.tsx | 26 ++++++++++++++++++++------ components/series/TVSeriesPage.tsx | 4 ++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/components/common/Input.tsx b/components/common/Input.tsx index fa3a29cca..7770f556c 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -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(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 = ( <> = ({ /> Date: Sat, 30 May 2026 16:54:35 +0200 Subject: [PATCH 295/309] 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. --- components/search/TVSearchPage.tsx | 246 +++++++++++--------- modules/tv-search/expo-module.config.json | 6 + modules/tv-search/index.ts | 2 + modules/tv-search/ios/TvSearch.podspec | 22 ++ modules/tv-search/ios/TvSearchModule.swift | 15 ++ modules/tv-search/ios/TvSearchView.swift | 206 ++++++++++++++++ modules/tv-search/package.json | 10 + modules/tv-search/src/TvSearchView.tsx | 22 ++ modules/tv-search/src/TvSearchView.types.ts | 12 + 9 files changed, 429 insertions(+), 112 deletions(-) create mode 100644 modules/tv-search/expo-module.config.json create mode 100644 modules/tv-search/index.ts create mode 100644 modules/tv-search/ios/TvSearch.podspec create mode 100644 modules/tv-search/ios/TvSearchModule.swift create mode 100644 modules/tv-search/ios/TvSearchView.swift create mode 100644 modules/tv-search/package.json create mode 100644 modules/tv-search/src/TvSearchView.tsx create mode 100644 modules/tv-search/src/TvSearchView.types.ts diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 69c7fc216..00ca4e1a6 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -1,13 +1,13 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useAtom } from "jotai"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { ScrollView, TVFocusGuideView, 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 { 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"; @@ -22,6 +22,10 @@ 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; @@ -124,7 +128,6 @@ interface TVSearchPageProps { } export const TVSearchPage: React.FC = ({ - search, setSearch, debouncedSearch, movies, @@ -157,6 +160,9 @@ export const TVSearchPage: React.FC = ({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); + // Ref to the native search view, used as a TVFocusGuideView destination so + // focus can be routed into it from the tab bar above. + const [searchViewRef, setSearchViewRef] = useState(null); // Image URL getter for music items const getImageUrl = useMemo(() => { @@ -215,125 +221,141 @@ export const TVSearchPage: React.FC = ({ const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults; return ( - - {/* Search Input */} + + {/* Sticky header: search field stays pinned while results scroll below. */} - - + {/* Focus bridge: routes a "down" press from the tab bar above into the + native search view (RN-tvOS won't traverse into the native container + on its own). */} + {searchViewRef && ( + + )} - {/* Search Type Tab Badges */} - {showDiscover && ( - - + setSearch(e.nativeEvent.text)} /> - )} + - {/* Loading State */} - {currentLoading && ( - - - - - )} - - {/* Library Search Results */} - {isLibraryMode && !loading && ( - - {sections.map((section, index) => ( - + {/* Search Type Tab Badges */} + {showDiscover && ( + + - ))} - - )} + + )} - {/* Jellyseerr/Discover Search Results */} - {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( - {})} - onTvPress={onJellyseerrTvPress || (() => {})} - onPersonPress={onJellyseerrPersonPress || (() => {})} - /> - )} + {/* Loading State */} + {currentLoading && ( + + + + + )} - {/* Discover Content (when no search query in Discover mode) */} - {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && ( - - )} + {/* Library Search Results */} + {isLibraryMode && !loading && ( + + {sections.map((section, index) => ( + + ))} + + )} - {/* No Results State */} - {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( - - - {t("search.no_results_found_for")} - - - "{debouncedSearch}" - - - )} - + {/* Jellyseerr/Discover Search Results */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( + {})} + onTvPress={onJellyseerrTvPress || (() => {})} + onPersonPress={onJellyseerrPersonPress || (() => {})} + /> + )} + + {/* Discover Content (when no search query in Discover mode) */} + {isDiscoverMode && + !jellyseerrLoading && + debouncedSearch.length === 0 && ( + + )} + + {/* No Results State */} + {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( + + + {t("search.no_results_found_for")} + + + "{debouncedSearch}" + + + )} + + ); }; diff --git a/modules/tv-search/expo-module.config.json b/modules/tv-search/expo-module.config.json new file mode 100644 index 000000000..b73df1517 --- /dev/null +++ b/modules/tv-search/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TvSearchModule"] + } +} diff --git a/modules/tv-search/index.ts b/modules/tv-search/index.ts new file mode 100644 index 000000000..5184d1129 --- /dev/null +++ b/modules/tv-search/index.ts @@ -0,0 +1,2 @@ +export { default as TvSearchView } from "./src/TvSearchView"; +export * from "./src/TvSearchView.types"; diff --git a/modules/tv-search/ios/TvSearch.podspec b/modules/tv-search/ios/TvSearch.podspec new file mode 100644 index 000000000..db0bfefa4 --- /dev/null +++ b/modules/tv-search/ios/TvSearch.podspec @@ -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 diff --git a/modules/tv-search/ios/TvSearchModule.swift b/modules/tv-search/ios/TvSearchModule.swift new file mode 100644 index 000000000..65b026c8d --- /dev/null +++ b/modules/tv-search/ios/TvSearchModule.swift @@ -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 ?? "") + } + } + } +} diff --git a/modules/tv-search/ios/TvSearchView.swift b/modules/tv-search/ios/TvSearchView.swift new file mode 100644 index 000000000..7fd44f718 --- /dev/null +++ b/modules/tv-search/ios/TvSearchView.swift @@ -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? + 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 diff --git a/modules/tv-search/package.json b/modules/tv-search/package.json new file mode 100644 index 000000000..c4ac53271 --- /dev/null +++ b/modules/tv-search/package.json @@ -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": {} +} diff --git a/modules/tv-search/src/TvSearchView.tsx b/modules/tv-search/src/TvSearchView.tsx new file mode 100644 index 000000000..aa1a81d29 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.tsx @@ -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 +> = 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((props, ref) => { + return ; +}); + +TvSearchView.displayName = "TvSearchView"; + +export default TvSearchView; diff --git a/modules/tv-search/src/TvSearchView.types.ts b/modules/tv-search/src/TvSearchView.types.ts new file mode 100644 index 000000000..011dbbcd0 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.types.ts @@ -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; +} From 6876ce046f19c80b85f1c7968d9fc9b96c26743e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 20:15:24 +0200 Subject: [PATCH 296/309] 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. --- app/(auth)/player/direct-player.tsx | 42 +++--- .../video-player/controls/Controls.tv.tsx | 45 +++--- utils/jellyfin/getDefaultPlaySettings.ts | 16 ++- utils/streamRanker.ts | 128 +++++++++++++++--- 4 files changed, 167 insertions(+), 64 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d14bf21fc..a384e94cf 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -185,11 +185,11 @@ export default function DirectPlayerPage() { 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(() => { @@ -470,8 +470,11 @@ export default function DirectPlayerPage() { 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 DirectPlayerPage() { }, [ stream, item?.Id, - audioIndex, - subtitleIndex, + currentAudioIndex, + currentSubtitleIndex, mediaSourceId, progress, isPlaying, @@ -553,8 +556,8 @@ export default function DirectPlayerPage() { }, [ item?.Id, - audioIndex, - subtitleIndex, + currentAudioIndex, + currentSubtitleIndex, mediaSourceId, isPlaying, stream, @@ -1009,8 +1012,9 @@ export default function DirectPlayerPage() { 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 +1033,8 @@ export default function DirectPlayerPage() { }, [ previousItem, settings, - subtitleIndex, - audioIndex, + currentSubtitleIndex, + currentAudioIndex, stream?.mediaSource, bitrateValue, router, @@ -1075,8 +1079,10 @@ export default function DirectPlayerPage() { 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 +1101,8 @@ export default function DirectPlayerPage() { }, [ nextItem, settings, - subtitleIndex, - audioIndex, + currentSubtitleIndex, + currentAudioIndex, stream?.mediaSource, bitrateValue, router, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 2faecbaaf..07506bad0 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -233,14 +233,8 @@ export const Controls: FC = ({ 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({ @@ -583,10 +577,22 @@ export const Controls: FC = ({ 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, @@ -598,6 +604,7 @@ export const Controls: FC = ({ (t) => t.index === -1, ); disableTrack?.setTrack(); + onSubtitleIndexChange?.(-1); }, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, refreshSubtitleTracks: onRefreshSubtitleTracks @@ -611,6 +618,7 @@ export const Controls: FC = ({ mediaSource?.Id, videoContextSubtitleTracks, subtitleIndex, + onSubtitleIndexChange, handleLocalSubtitleDownloaded, onRefreshSubtitleTracks, refreshSubtitleTracks, @@ -1031,13 +1039,12 @@ export const Controls: FC = ({ 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 { @@ -1064,8 +1071,8 @@ export const Controls: FC = ({ [ nextItem, settings, - paramSubtitleIndex, - paramAudioIndex, + subtitleIndex, + audioIndex, mediaSource, bitrateValue, router, diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index bfbfb526a..b4da6b1e1 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -205,15 +205,19 @@ export function getDefaultPlaySettings( previous.indexes.subtitleIndex !== undefined ) { const ranker = new StreamRanker(new SubtitleStreamRanker()); - const result = { DefaultSubtitleStreamIndex: subtitleIndex }; + const result = { + DefaultSubtitleStreamIndex: subtitleIndex, + matched: false, + }; ranker.rankStream( previous.indexes.subtitleIndex, previous.source, streams, result, ); - // Check if StreamRanker found a match (changed from default) - if (result.DefaultSubtitleStreamIndex !== subtitleIndex) { + // Use the ranker's explicit match signal — this also covers a deliberate + // "subtitles off" (-1) and the case where the match equals the default. + if (result.matched) { subtitleIndex = result.DefaultSubtitleStreamIndex; matchedPreviousSubtitle = true; } @@ -224,15 +228,15 @@ export function getDefaultPlaySettings( previous.indexes.audioIndex !== undefined ) { const ranker = new StreamRanker(new AudioStreamRanker()); - const result = { DefaultAudioStreamIndex: audioIndex }; + const result = { DefaultAudioStreamIndex: audioIndex, matched: false }; ranker.rankStream( previous.indexes.audioIndex, previous.source, streams, result, ); - // Check if StreamRanker found a match (changed from default) - if (result.DefaultAudioStreamIndex !== audioIndex) { + // Use the ranker's explicit match signal + if (result.matched) { audioIndex = result.DefaultAudioStreamIndex; matchedPreviousAudio = true; } diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts index 242cf950d..6e90cfc0e 100644 --- a/utils/streamRanker.ts +++ b/utils/streamRanker.ts @@ -13,6 +13,42 @@ abstract class StreamRankerStrategy { trackOptions: any, ): void; + /** + * Score how well a candidate stream matches the previously selected stream. + * Overridable so subtitle ranking can add mode (forced / hearing-impaired) + * awareness without changing audio behavior. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + if ( + prevStream.Language && + prevStream.Language !== "und" && + prevStream.Language === stream.Language + ) { + score += 2; + } + + return score; + } + protected rank( prevIndex: number, prevSource: MediaSourceInfo, @@ -22,6 +58,9 @@ abstract class StreamRankerStrategy { if (prevIndex === -1) { console.debug("AutoSet Subtitle - No Stream Set"); trackOptions[`Default${this.streamType}StreamIndex`] = -1; + // A deliberate "off" selection is a valid match to retain — flag it so + // callers don't fall back to language preferences / subtitle mode. + trackOptions.matched = true; return; } @@ -63,27 +102,12 @@ abstract class StreamRankerStrategy { continue; } - let score = 0; - - if (prevStream.Codec === stream.Codec) { - score += 1; - } - if (prevRelIndex === newRelIndex) { - score += 1; - } - if ( - prevStream.DisplayTitle && - prevStream.DisplayTitle === stream.DisplayTitle - ) { - score += 2; - } - if ( - prevStream.Language && - prevStream.Language !== "und" && - prevStream.Language === stream.Language - ) { - score += 2; - } + const score = this.computeScore( + prevStream, + stream, + prevRelIndex, + newRelIndex, + ); console.debug( `AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`, @@ -101,6 +125,7 @@ abstract class StreamRankerStrategy { `AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`, ); trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex; + trackOptions.matched = true; } else { console.debug( `AutoSet ${this.streamType} - Threshold not met. Using default.`, @@ -112,6 +137,67 @@ abstract class StreamRankerStrategy { class SubtitleStreamRanker extends StreamRankerStrategy { streamType = "Subtitle"; + /** + * Subtitle scoring that retains both language and mode across episodes. + * + * - When the previous track has a language: a language match is weighted high + * (+3) so it clears the threshold even when codec / title / position differ, + * and mode (forced / hearing-impaired) acts as a tiebreaker among + * same-language tracks. Different-language candidates get no language or mode + * points, so they can never be selected on mode alone (no cross-language + * hijack). + * - When the previous track has NO usable language (common for SRT/SUBRIP): + * language can't help, so mode (forced / hearing-impaired) + codec + relative + * position become the identity signal. Without this, unlabeled subtitles + * score only codec+relIndex (≤2) and the selection is silently lost. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + + const prevHasLanguage = + !!prevStream.Language && prevStream.Language !== "und"; + const languageMatches = + prevHasLanguage && prevStream.Language === stream.Language; + + if (languageMatches) { + score += 3; + } else if (prevHasLanguage) { + // Previous track had a language but this candidate's differs — do not award + // mode points, so a different language is never matched on mode alone. + return score; + } + + // Either the language matched, or the previous track had no language (so mode + // is the primary identity). Normalize the flags to booleans since + // IsForced / IsHearingImpaired may be undefined. + if (!!prevStream.IsForced === !!stream.IsForced) { + score += 2; + } + if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) { + score += 1; + } + + return score; + } + rankStream( prevIndex: number, prevSource: MediaSourceInfo, From d2e73021b1e4fa2973e330c9e48219565282691b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 20:15:37 +0200 Subject: [PATCH 297/309] 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. --- .../modules/mpvplayer/MPVLayerRenderer.kt | 16 +++++++++---- modules/mpv-player/ios/MPVLayerRenderer.swift | 23 ++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 98debe637..ff45438eb 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -684,11 +684,19 @@ 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. + if (initialAudioId != null && initialAudioId > 0) { + setAudioTrack(initialAudioId) + } + initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() + if (!isReadyToSeek) { isReadyToSeek = true mainHandler.post { delegate?.onReadyToSeek() } diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 8c43b9e4e..ebd072f7c 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -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 From 252c58f1203358fb2c99185fd0b551c8dd5ffa80 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 21:21:22 +0200 Subject: [PATCH 298/309] fix(tv): lazy-load @expo/ui to prevent tvOS crash at module load --- components/PlatformDropdown.tsx | 15 ++++++++++++--- components/search/DiscoverFilters.tsx | 14 +++++++++++--- components/search/SearchTabButtons.tsx | 14 +++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 8f81e567e..aaea71b3f 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,5 +1,3 @@ -import { Button, Host, Menu } from "@expo/ui/swift-ui"; -import { disabled } from "@expo/ui/swift-ui/modifiers"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect, useState } from "react"; @@ -14,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 = { type: "radio"; @@ -255,7 +264,7 @@ 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 (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` diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 59fd51c94..3f70c968d 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -1,9 +1,17 @@ -import { Button, Host, Menu } from "@expo/ui/swift-ui"; -import { buttonStyle } 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; @@ -29,7 +37,7 @@ export const DiscoverFilters: React.FC = ({ setJellyseerrSortOrder, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( = ({ setSearchType, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( From 5a3e9c51c98041430369f4ff1986cf6bee1aa1d9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 21:21:37 +0200 Subject: [PATCH 299/309] 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. --- components/search/TVSearchPage.tsx | 47 ++++++++++++------------------ 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 00ca4e1a6..ab928d845 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -1,11 +1,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ScrollView, TVFocusGuideView, View } from "react-native"; +import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { TvSearchView } from "@/modules/tv-search"; import { apiAtom } from "@/providers/JellyfinProvider"; @@ -16,6 +17,7 @@ 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"; @@ -29,27 +31,32 @@ 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 ( + {/* Section header placeholder — matches the heading typography + margins */} @@ -60,15 +67,14 @@ const TVLoadingSkeleton: React.FC = () => { backgroundColor: "#262626", width: itemWidth, aspectRatio: 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(24), + marginBottom: scaleSize(8), }} /> @@ -160,9 +166,6 @@ export const TVSearchPage: React.FC = ({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); - // Ref to the native search view, used as a TVFocusGuideView destination so - // focus can be routed into it from the tab bar above. - const [searchViewRef, setSearchViewRef] = useState(null); // Image URL getter for music items const getImageUrl = useMemo(() => { @@ -228,30 +231,18 @@ export const TVSearchPage: React.FC = ({ paddingTop: insets.top + TOP_PADDING, }} > - {/* Focus bridge: routes a "down" press from the tab bar above into the - native search view (RN-tvOS won't traverse into the native container - on its own). */} - {searchViewRef && ( - - )} - {/* 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. */} setSearch(e.nativeEvent.text)} From aedb7bc51da524fd01e2ab22565236923aafb372 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 21:41:27 +0200 Subject: [PATCH 300/309] fix(tv): padding --- components/search/TVSearchSection.tsx | 12 ++++++++---- constants/TVSizes.ts | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index 5fd3dd37e..3d72de8df 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -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 = ({ @@ -31,12 +33,14 @@ export const TVSearchSection: React.FC = ({ 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>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -273,7 +277,7 @@ export const TVSearchSection: React.FC = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: sizes.padding.horizontal, + marginLeft: edgePadding, letterSpacing: 0.5, }} > @@ -294,10 +298,10 @@ export const TVSearchSection: React.FC = ({ 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, }} diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts index 0a9196c41..117609021 100644 --- a/constants/TVSizes.ts +++ b/constants/TVSizes.ts @@ -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, From a205c758957b2536518b2bd35d55628ae214abe2 Mon Sep 17 00:00:00 2001 From: Alex <111128610+Alexk2309@users.noreply.github.com> Date: Sun, 31 May 2026 06:45:07 +1000 Subject: [PATCH 301/309] chore(mpv-player): Update to MPVKIT 0.41 (#1604) --- app.json | 4 +-- modules/mpv-player/ios/MpvPlayer.podspec | 31 +++++++----------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/app.json b/app.json index ed6ddf194..6dee6c85a 100644 --- a/app.json +++ b/app.json @@ -144,8 +144,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" } ] ], diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec index 2a6c2ed66..4aad64440 100644 --- a/modules/mpv-player/ios/MpvPlayer.podspec +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -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}" From 27dc7b5664d3a82939ea73344c63c726b35b63e9 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Sat, 30 May 2026 22:45:19 +0200 Subject: [PATCH 302/309] fix: android pip (#1605) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app/(auth)/player/direct-player.tsx | 6 +- .../modules/mpvplayer/MPVLayerRenderer.kt | 59 ++- .../expo/modules/mpvplayer/MpvPlayerModule.kt | 2 +- .../expo/modules/mpvplayer/MpvPlayerView.kt | 264 ++++++++---- .../expo/modules/mpvplayer/PiPController.kt | 407 ++++++++++++------ modules/mpv-player/src/MpvPlayer.types.ts | 7 + modules/mpv-player/src/MpvPlayerView.tsx | 14 +- 7 files changed, 518 insertions(+), 241 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index a384e94cf..937c32092 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -825,12 +825,10 @@ export default function DirectPlayerPage() { ], ); - /** 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); } @@ -848,6 +846,9 @@ export default function DirectPlayerPage() { // 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?.(); }, []); @@ -1253,6 +1254,7 @@ export default function DirectPlayerPage() { nowPlayingMetadata={nowPlayingMetadata} onProgress={onProgress} onPlaybackStateChange={onPlaybackStateChanged} + onPictureInPictureChange={_onPictureInPictureChange} onLoad={() => setIsVideoLoaded(true)} onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { console.error("Video Error:", e.nativeEvent); diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index ff45438eb..753bfb28f 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -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")) } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 2e3f9a868..2d1cfddd5 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -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") } } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 066afe90b..4df7fe0b3 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -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? = 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> { 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> { 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()) } - + 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() diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt index 438ccaa1f..2a24440bf 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -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 { + 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 + ) } } - diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 9de7ad60f..b6bd04711 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -25,6 +25,10 @@ export type OnErrorEventPayload = { export type OnTracksReadyEventPayload = Record; +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 { diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index cec13b0ff..1e1c80659 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types"; const NativeView: React.ComponentType = requireNativeView("MpvPlayer"); +const PIP_LOG = "[PiP] MpvPlayerView.tsx:"; + export default React.forwardRef( function MpvPlayerView(props, ref) { const nativeRef = useRef(null); @@ -40,16 +42,24 @@ export default React.forwardRef( 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(); From 86e39c444c6e7ffba08b079aeaf5ba1141463750 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 09:37:08 +0200 Subject: [PATCH 303/309] fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) --- bun.lock | 5 ---- package.json | 7 +---- .../react-native-bottom-tabs+1.2.0.patch | 27 +++++++++---------- .../react-native-ios-utilities+5.2.0.patch | 6 ++--- .../react-native-udp+4.1.7.patch | 9 +++---- plugins/with-runtime-framework-headers.js | 22 +++++++++++++++ 6 files changed, 41 insertions(+), 35 deletions(-) rename bun-patches/react-native-bottom-tabs@1.2.0.patch => patches/react-native-bottom-tabs+1.2.0.patch (69%) rename bun-patches/react-native-ios-utilities@5.2.0.patch => patches/react-native-ios-utilities+5.2.0.patch (74%) rename bun-patches/react-native-udp@4.1.7.patch => patches/react-native-udp+4.1.7.patch (62%) diff --git a/bun.lock b/bun.lock index ed6a2e46b..aa50de168 100644 --- a/bun.lock +++ b/bun.lock @@ -114,11 +114,6 @@ }, }, }, - "patchedDependencies": { - "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch", - "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", - "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", - }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], diff --git a/package.json b/package.json index f7256b9a5..74bcd674d 100644 --- a/package.json +++ b/package.json @@ -162,10 +162,5 @@ }, "trustedDependencies": [ "unrs-resolver" - ], - "patchedDependencies": { - "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", - "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", - "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch" - } + ] } diff --git a/bun-patches/react-native-bottom-tabs@1.2.0.patch b/patches/react-native-bottom-tabs+1.2.0.patch similarity index 69% rename from bun-patches/react-native-bottom-tabs@1.2.0.patch rename to patches/react-native-bottom-tabs+1.2.0.patch index 9483b873c..e44815ef3 100644 --- a/bun-patches/react-native-bottom-tabs@1.2.0.patch +++ b/patches/react-native-bottom-tabs+1.2.0.patch @@ -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 diff --git a/bun-patches/react-native-ios-utilities@5.2.0.patch b/patches/react-native-ios-utilities+5.2.0.patch similarity index 74% rename from bun-patches/react-native-ios-utilities@5.2.0.patch rename to patches/react-native-ios-utilities+5.2.0.patch index 4659493ba..4288dfcc3 100644 --- a/bun-patches/react-native-ios-utilities@5.2.0.patch +++ b/patches/react-native-ios-utilities+5.2.0.patch @@ -1,7 +1,7 @@ -diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +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/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift -+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +--- 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); }; diff --git a/bun-patches/react-native-udp@4.1.7.patch b/patches/react-native-udp+4.1.7.patch similarity index 62% rename from bun-patches/react-native-udp@4.1.7.patch rename to patches/react-native-udp+4.1.7.patch index 823acb86e..656a73aca 100644 --- a/bun-patches/react-native-udp@4.1.7.patch +++ b/patches/react-native-udp+4.1.7.patch @@ -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"] diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.js index 23e7d1011..8405239b8 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.js @@ -39,6 +39,28 @@ function buildPatch() { " end", " end", "", + " # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,", + " # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).", + " # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object", + " # files, so the pods-only flag above is not enough — the app's link still fails with", + " # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target", + " # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.", + " if ENV['EXPO_TV'] != '1'", + " installer.aggregate_targets.each do |agg|", + " next unless agg.user_project", + " agg.user_project.native_targets.each do |target|", + " target.build_configurations.each do |cfg|", + " existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'", + " existing = existing.join(' ') if existing.is_a?(Array)", + " unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')", + " cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'", + " end", + " end", + " end", + " agg.user_project.save", + " 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)", From 692ccfdb2cc18ab1fda0a0a442aa4cfb04b950e3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 09:42:09 +0200 Subject: [PATCH 304/309] 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. --- targets/StreamyfinTopShelf/Info.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/targets/StreamyfinTopShelf/Info.plist b/targets/StreamyfinTopShelf/Info.plist index 184b8d3d4..592d0c719 100644 --- a/targets/StreamyfinTopShelf/Info.plist +++ b/targets/StreamyfinTopShelf/Info.plist @@ -29,6 +29,10 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + UIRequiredDeviceCapabilities + + arm64 + NSExtension NSExtensionPointIdentifier From d585b20f49c052e6536833feb38bfad2ab129d8c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 09:44:05 +0200 Subject: [PATCH 305/309] chore: version --- app.json | 4 ++-- eas.json | 8 ++++---- providers/JellyfinProvider.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index 6dee6c85a..92ca6861f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.54.0", + "version": "0.54.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -36,7 +36,7 @@ "appleTeamId": "MWD5K362T8" }, "android": { - "versionCode": 93, + "versionCode": 94, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/eas.json b/eas.json index 03f933895..3c125b629 100644 --- a/eas.json +++ b/eas.json @@ -52,14 +52,14 @@ }, "production": { "environment": "production", - "channel": "0.54.0", + "channel": "0.54.1", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.54.0", + "channel": "0.54.1", "android": { "buildType": "apk", "image": "latest" @@ -67,7 +67,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.54.0", + "channel": "0.54.1", "android": { "buildType": "apk", "image": "latest" @@ -78,7 +78,7 @@ }, "production_tv": { "environment": "production", - "channel": "0.54.0", + "channel": "0.54.1", "env": { "EXPO_TV": "1" }, diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 7dff4b366..e6f9853ae 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -53,7 +53,7 @@ const initialApi = (() => { const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); const jellyfinInstance = new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.0" }, + clientInfo: { name: "Streamyfin", version: "0.54.1" }, deviceInfo: { name: deviceName, id, @@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); return new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.0" }, + clientInfo: { name: "Streamyfin", version: "0.54.1" }, deviceInfo: { name: deviceName, id, @@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.54.0"`, + }, DeviceId="${deviceId}", Version="0.54.1"`, }; }, [deviceId]); From 6b6bfd1a893677eb2e4ea4e0471fb13d37983373 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 10:48:24 +0200 Subject: [PATCH 306/309] fix(player): remove white blob artifacts on vertical sliders --- components/video-player/controls/AudioSlider.tsx | 4 ++-- components/video-player/controls/BrightnessSlider.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx index 9f70fbba9..31c90483c 100644 --- a/components/video-player/controls/AudioSlider.tsx +++ b/components/video-player/controls/AudioSlider.tsx @@ -105,14 +105,14 @@ const AudioSlider: React.FC = ({ 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 }} /> { 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 }} /> Date: Sun, 31 May 2026 10:50:06 +0200 Subject: [PATCH 307/309] fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) --- .../src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 753bfb28f..8b6808fdb 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -715,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { // dropped), so we (re)apply here for embedded and external alike. // This is what makes a carried-over subtitle show up on the next // episode without a manual re-selection. - if (initialAudioId != null && initialAudioId > 0) { - setAudioTrack(initialAudioId) - } + initialAudioId?.let { if (it > 0) setAudioTrack(it) } initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() if (!isReadyToSeek) { From 2761de5a7498661a8b39c662bc08a5f09ff48696 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 11:11:55 +0200 Subject: [PATCH 308/309] 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). --- app.json | 1 - eas.json | 11 ++++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app.json b/app.json index 92ca6861f..296d674de 100644 --- a/app.json +++ b/app.json @@ -36,7 +36,6 @@ "appleTeamId": "MWD5K362T8" }, "android": { - "versionCode": 94, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/eas.json b/eas.json index 3c125b629..cc5eb28c7 100644 --- a/eas.json +++ b/eas.json @@ -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.1", + "autoIncrement": true, "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.54.1", + "autoIncrement": true, "android": { "buildType": "apk", "image": "latest" @@ -67,7 +68,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.54.1", + "autoIncrement": true, "android": { "buildType": "apk", "image": "latest" @@ -78,7 +79,7 @@ }, "production_tv": { "environment": "production", - "channel": "0.54.1", + "autoIncrement": true, "env": { "EXPO_TV": "1" }, From fa1c3f39479f4ea8f3ca735c5dbe9dae64d5640d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 31 May 2026 11:22:58 +0200 Subject: [PATCH 309/309] chore(eas): pin appleTeamId and ascAppId in submit profiles Avoid the interactive Apple team picker and app-existence lookup on submit by pinning the Individual team (MWD5K362T8) and ASC App ID. --- eas.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/eas.json b/eas.json index cc5eb28c7..afc774889 100644 --- a/eas.json +++ b/eas.json @@ -89,7 +89,17 @@ } }, "submit": { - "production": {}, - "production_tv": {} + "production": { + "ios": { + "appleTeamId": "MWD5K362T8", + "ascAppId": "6593660679" + } + }, + "production_tv": { + "ios": { + "appleTeamId": "MWD5K362T8", + "ascAppId": "6593660679" + } + } } }

    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 H^H=`|ZlJlt literal 0 HcmV?d00001 diff --git a/assets/images/icon-tvos.png b/assets/images/icon-tvos.png new file mode 100644 index 0000000000000000000000000000000000000000..2e0764c406c669314e07bf77da8398022a4d36a1 GIT binary patch literal 71426 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 = ({ ); +// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { const [api] = useAtom(apiAtom); @@ -608,3 +609,6 @@ export const ItemContentTV: React.FC = React.memo( ); }, ); + +// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files) +export const ItemContent = ItemContentTV; diff --git a/plugins/withTVOSAppIcon.js b/plugins/withTVOSAppIcon.js new file mode 100644 index 000000000..50114eb6b --- /dev/null +++ b/plugins/withTVOSAppIcon.js @@ -0,0 +1,31 @@ +const { withXcodeProject } = require("@expo/config-plugins"); + +const withTVOSAppIcon = (config) => { + // Only apply for TV builds + if (process.env.EXPO_TV !== "1") { + return config; + } + + return withXcodeProject(config, async (config) => { + const xcodeProject = config.modResults; + + const buildConfigurations = xcodeProject.pbxXCBuildConfigurationSection(); + + for (const key in buildConfigurations) { + const buildConfig = buildConfigurations[key]; + if ( + typeof buildConfig === "object" && + buildConfig.buildSettings && + buildConfig.buildSettings.PRODUCT_NAME + ) { + // Set the tvOS app icon + buildConfig.buildSettings.ASSETCATALOG_COMPILER_APPICON_NAME = + "TVAppIcon"; + } + } + + return config; + }); +}; + +module.exports = withTVOSAppIcon; From 4880392197cd9401daa89f6e930f46a1eb6c09c3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 12:19:47 +0100 Subject: [PATCH 017/309] fix(tv): login form --- app/login.tsx | 500 +----------------- components/login/Login.tsx | 456 ++++++++++++++++ .../login/TVLogin.tsx | 128 +++-- 3 files changed, 547 insertions(+), 537 deletions(-) create mode 100644 components/login/Login.tsx rename app/login.tv.tsx => components/login/TVLogin.tsx (84%) diff --git a/app/login.tsx b/app/login.tsx index 20c574b20..d028ae4f2 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,497 +1,13 @@ -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; -import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; -import { t } from "i18next"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useState } from "react"; -import { - Alert, - Keyboard, - KeyboardAvoidingView, - Platform, - Switch, - TouchableOpacity, - View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { z } from "zod"; -import { Button } from "@/components/Button"; -import { Input } from "@/components/common/Input"; -import { Text } from "@/components/common/Text"; -import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; -import { PreviousServersList } from "@/components/PreviousServersList"; -import { SaveAccountModal } from "@/components/SaveAccountModal"; -import { Colors } from "@/constants/Colors"; -import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; -import type { - AccountSecurityType, - SavedServer, -} from "@/utils/secureCredentials"; +import { Platform } from "react-native"; +import { Login } from "@/components/login/Login"; +import { TVLogin } from "@/components/login/TVLogin"; -const CredentialsSchema = z.object({ - username: z.string().min(1, t("login.username_required")), -}); - -const Login: React.FC = () => { - const api = useAtomValue(apiAtom); - const navigation = useNavigation(); - const params = useLocalSearchParams(); - const { - setServer, - login, - removeServer, - initiateQuickConnect, - loginWithSavedCredential, - loginWithPassword, - } = useJellyfin(); - - const { - apiUrl: _apiUrl, - username: _username, - password: _password, - } = params as { apiUrl: string; username: string; password: string }; - - const [loadingServerCheck, setLoadingServerCheck] = useState(false); - const [loading, setLoading] = useState(false); - const [serverURL, setServerURL] = useState(_apiUrl || ""); - const [serverName, setServerName] = useState(""); - const [credentials, setCredentials] = useState<{ - username: string; - password: string; - }>({ - username: _username || "", - password: _password || "", - }); - - // Save account state - const [saveAccount, setSaveAccount] = useState(false); - const [showSaveModal, setShowSaveModal] = useState(false); - const [pendingLogin, setPendingLogin] = useState<{ - username: string; - password: string; - } | null>(null); - - /** - * A way to auto login based on a link - */ - useEffect(() => { - (async () => { - if (_apiUrl) { - await setServer({ - address: _apiUrl, - }); - - // Wait for server setup and state updates to complete - setTimeout(() => { - if (_username && _password) { - setCredentials({ username: _username, password: _password }); - login(_username, _password); - } - }, 0); - } - })(); - }, [_apiUrl, _username, _password]); - - useEffect(() => { - navigation.setOptions({ - headerTitle: serverName, - headerLeft: () => - api?.basePath ? ( - { - removeServer(); - }} - className='flex flex-row items-center pr-2 pl-1' - > - - - {t("login.change_server")} - - - ) : null, - }); - }, [serverName, navigation, api?.basePath]); - - const handleLogin = async () => { - Keyboard.dismiss(); - - const result = CredentialsSchema.safeParse(credentials); - if (!result.success) return; - - if (saveAccount) { - // Show save account modal to choose security type - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - // Login without saving - await performLogin(credentials.username, credentials.password); - } - }; - - const performLogin = async ( - username: string, - password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { - setLoading(true); - try { - await login(username, password, serverName, options); - } catch (error) { - if (error instanceof Error) { - Alert.alert(t("login.connection_failed"), error.message); - } else { - Alert.alert( - t("login.connection_failed"), - t("login.an_unexpected_error_occured"), - ); - } - } finally { - setLoading(false); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); - } - }; - - const handleQuickLoginWithSavedCredential = async ( - serverUrl: string, - userId: string, - ) => { - await loginWithSavedCredential(serverUrl, userId); - }; - - const handlePasswordLogin = async ( - serverUrl: string, - username: string, - password: string, - ) => { - await loginWithPassword(serverUrl, username, password); - }; - - const handleAddAccount = (server: SavedServer) => { - // Server is already selected, go to credential entry - setServer({ address: server.address }); - if (server.name) { - setServerName(server.name); - } - }; - - /** - * Checks the availability and validity of a Jellyfin server URL. - * - * This function attempts to connect to a Jellyfin server using the provided URL. - * It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses. - * - * @param {string} url - The base URL of the Jellyfin server to check. - * @returns {Promise} A Promise that resolves to: - * - The full URL (including protocol) if a valid Jellyfin server is found. - * - undefined if no valid server is found at the given URL. - * - * Side effects: - * - Sets loadingServerCheck state to true at the beginning and false at the end. - * - Logs errors and timeout information to the console. - */ - const checkUrl = useCallback(async (url: string) => { - setLoadingServerCheck(true); - const baseUrl = url.replace(/^https?:\/\//i, ""); - const protocols = ["https", "http"]; - try { - return checkHttp(baseUrl, protocols); - } catch (e) { - if (e instanceof Error && e.message === "Server too old") { - throw e; - } - return undefined; - } finally { - setLoadingServerCheck(false); - } - }, []); - - async function checkHttp(baseUrl: string, protocols: string[]) { - for (const protocol of protocols) { - try { - const response = await fetch( - `${protocol}://${baseUrl}/System/Info/Public`, - { - mode: "cors", - }, - ); - if (response.ok) { - const data = (await response.json()) as PublicSystemInfo; - const serverVersion = data.Version?.split("."); - if (serverVersion && +serverVersion[0] <= 10) { - if (+serverVersion[1] < 10) { - Alert.alert( - t("login.too_old_server_text"), - t("login.too_old_server_description"), - ); - throw new Error("Server too old"); - } - } - setServerName(data.ServerName || ""); - return `${protocol}://${baseUrl}`; - } - } catch (e) { - if (e instanceof Error && e.message === "Server too old") { - throw e; - } - } - } - return undefined; +const LoginPage: React.FC = () => { + if (Platform.isTV) { + return ; } - /** - * Handles the connection attempt to a Jellyfin server. - * - * This function trims the input URL, checks its validity using the `checkUrl` function, - * and sets the server address if a valid connection is established. - * - * @param {string} url - The URL of the Jellyfin server to connect to. - * - * @returns {Promise} - * - * Side effects: - * - Calls `checkUrl` to validate the server URL. - * - Shows an alert if the connection fails. - * - Sets the server address using `setServer` if the connection is successful. - * - */ - const handleConnect = useCallback(async (url: string) => { - url = url.trim().replace(/\/$/, ""); - try { - const result = await checkUrl(url); - if (result === undefined) { - Alert.alert( - t("login.connection_failed"), - t("login.could_not_connect_to_server"), - ); - return; - } - await setServer({ address: result }); - } catch {} - }, []); - const handleQuickConnect = async () => { - try { - const code = await initiateQuickConnect(); - if (code) { - Alert.alert( - t("login.quick_connect"), - t("login.enter_code_to_login", { code: code }), - [ - { - text: t("login.got_it"), - }, - ], - ); - } - } catch (_error) { - Alert.alert( - t("login.error_title"), - t("login.failed_to_initiate_quick_connect"), - ); - } - }; - - return ( - // Mobile layout - - - {api?.basePath ? ( - - - - - {serverName ? ( - <> - {`${t("login.login_to_title")} `} - {serverName} - - ) : ( - t("login.login_title") - )} - - {api.basePath} - - setCredentials((prev) => ({ ...prev, username: text })) - } - onEndEditing={(e) => { - const newValue = e.nativeEvent.text; - if (newValue && newValue !== credentials.username) { - setCredentials((prev) => ({ - ...prev, - username: newValue, - })); - } - }} - value={credentials.username} - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - autoCorrect={false} - textContentType='username' - clearButtonMode='while-editing' - maxLength={500} - /> - - - setCredentials((prev) => ({ ...prev, password: text })) - } - onEndEditing={(e) => { - const newValue = e.nativeEvent.text; - if (newValue && newValue !== credentials.password) { - setCredentials((prev) => ({ - ...prev, - password: newValue, - })); - } - }} - value={credentials.password} - secureTextEntry - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - textContentType='password' - clearButtonMode='while-editing' - maxLength={500} - /> - setSaveAccount(!saveAccount)} - className='flex flex-row items-center py-2' - activeOpacity={0.7} - > - - - {t("save_account.save_for_later")} - - - - - - - - - - - - - - ) : ( - - - - Streamyfin - - {t("server.enter_url_to_jellyfin_server")} - - - - { - setServerURL(server.address); - if (server.serverName) { - setServerName(server.serverName); - } - await handleConnect(server.address); - }} - /> - { - await handleConnect(s.address); - }} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - /> - - - )} - - - {/* Save Account Modal */} - { - setShowSaveModal(false); - setPendingLogin(null); - }} - onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} - /> - - ); + return ; }; -export default Login; +export default LoginPage; diff --git a/components/login/Login.tsx b/components/login/Login.tsx new file mode 100644 index 000000000..0b1fedc7d --- /dev/null +++ b/components/login/Login.tsx @@ -0,0 +1,456 @@ +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { t } from "i18next"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Keyboard, + KeyboardAvoidingView, + Platform, + Switch, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { z } from "zod"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; +import { PreviousServersList } from "@/components/PreviousServersList"; +import { SaveAccountModal } from "@/components/SaveAccountModal"; +import { Colors } from "@/constants/Colors"; +import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import type { + AccountSecurityType, + SavedServer, +} from "@/utils/secureCredentials"; + +const CredentialsSchema = z.object({ + username: z.string().min(1, t("login.username_required")), +}); + +export const Login: React.FC = () => { + const api = useAtomValue(apiAtom); + const navigation = useNavigation(); + const params = useLocalSearchParams(); + const { + setServer, + login, + removeServer, + initiateQuickConnect, + loginWithSavedCredential, + loginWithPassword, + } = useJellyfin(); + + const { + apiUrl: _apiUrl, + username: _username, + password: _password, + } = params as { apiUrl: string; username: string; password: string }; + + const [loadingServerCheck, setLoadingServerCheck] = useState(false); + const [loading, setLoading] = useState(false); + const [serverURL, setServerURL] = useState(_apiUrl || ""); + const [serverName, setServerName] = useState(""); + const [credentials, setCredentials] = useState<{ + username: string; + password: string; + }>({ + username: _username || "", + password: _password || "", + }); + + // Save account state + const [saveAccount, setSaveAccount] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [pendingLogin, setPendingLogin] = useState<{ + username: string; + password: string; + } | null>(null); + + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ + address: _apiUrl, + }); + + setTimeout(() => { + if (_username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, 0); + } + })(); + }, [_apiUrl, _username, _password]); + + useEffect(() => { + navigation.setOptions({ + headerTitle: serverName, + headerLeft: () => + api?.basePath ? ( + { + removeServer(); + }} + className='flex flex-row items-center pr-2 pl-1' + > + + + {t("login.change_server")} + + + ) : null, + }); + }, [serverName, navigation, api?.basePath]); + + const handleLogin = async () => { + Keyboard.dismiss(); + + const result = CredentialsSchema.safeParse(credentials); + if (!result.success) return; + + if (saveAccount) { + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + } else { + await performLogin(credentials.username, credentials.password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + const handleQuickLoginWithSavedCredential = async ( + serverUrl: string, + userId: string, + ) => { + await loginWithSavedCredential(serverUrl, userId); + }; + + const handlePasswordLogin = async ( + serverUrl: string, + username: string, + password: string, + ) => { + await loginWithPassword(serverUrl, username, password); + }; + + const handleAddAccount = (server: SavedServer) => { + setServer({ address: server.address }); + if (server.name) { + setServerName(server.name); + } + }; + + const checkUrl = useCallback(async (url: string) => { + setLoadingServerCheck(true); + const baseUrl = url.replace(/^https?:\/\//i, ""); + const protocols = ["https", "http"]; + try { + return checkHttp(baseUrl, protocols); + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + return undefined; + } finally { + setLoadingServerCheck(false); + } + }, []); + + async function checkHttp(baseUrl: string, protocols: string[]) { + for (const protocol of protocols) { + try { + const response = await fetch( + `${protocol}://${baseUrl}/System/Info/Public`, + { + mode: "cors", + }, + ); + if (response.ok) { + const data = (await response.json()) as PublicSystemInfo; + const serverVersion = data.Version?.split("."); + if (serverVersion && +serverVersion[0] <= 10) { + if (+serverVersion[1] < 10) { + Alert.alert( + t("login.too_old_server_text"), + t("login.too_old_server_description"), + ); + throw new Error("Server too old"); + } + } + setServerName(data.ServerName || ""); + return `${protocol}://${baseUrl}`; + } + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + } + } + return undefined; + } + + const handleConnect = useCallback(async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + } catch {} + }, []); + + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert( + t("login.quick_connect"), + t("login.enter_code_to_login", { code: code }), + [ + { + text: t("login.got_it"), + }, + ], + ); + } + } catch (_error) { + Alert.alert( + t("login.error_title"), + t("login.failed_to_initiate_quick_connect"), + ); + } + }; + + return ( + + + {api?.basePath ? ( + + + + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + {api.basePath} + + setCredentials((prev) => ({ ...prev, username: text })) + } + onEndEditing={(e) => { + const newValue = e.nativeEvent.text; + if (newValue && newValue !== credentials.username) { + setCredentials((prev) => ({ + ...prev, + username: newValue, + })); + } + }} + value={credentials.username} + keyboardType='default' + returnKeyType='done' + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + clearButtonMode='while-editing' + maxLength={500} + /> + + + setCredentials((prev) => ({ ...prev, password: text })) + } + onEndEditing={(e) => { + const newValue = e.nativeEvent.text; + if (newValue && newValue !== credentials.password) { + setCredentials((prev) => ({ + ...prev, + password: newValue, + })); + } + }} + value={credentials.password} + secureTextEntry + keyboardType='default' + returnKeyType='done' + autoCapitalize='none' + textContentType='password' + clearButtonMode='while-editing' + maxLength={500} + /> + setSaveAccount(!saveAccount)} + className='flex flex-row items-center py-2' + activeOpacity={0.7} + > + + + {t("save_account.save_for_later")} + + + + + + + + + + + + + + ) : ( + + + + Streamyfin + + {t("server.enter_url_to_jellyfin_server")} + + + + { + setServerURL(server.address); + if (server.serverName) { + setServerName(server.serverName); + } + await handleConnect(server.address); + }} + /> + { + await handleConnect(s.address); + }} + onQuickLogin={handleQuickLoginWithSavedCredential} + onPasswordLogin={handlePasswordLogin} + onAddAccount={handleAddAccount} + /> + + + )} + + + { + setShowSaveModal(false); + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || credentials.username} + /> + + ); +}; diff --git a/app/login.tv.tsx b/components/login/TVLogin.tsx similarity index 84% rename from app/login.tv.tsx rename to components/login/TVLogin.tsx index 498907c01..20b9cb05b 100644 --- a/app/login.tv.tsx +++ b/components/login/TVLogin.tsx @@ -5,7 +5,13 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useState } from "react"; -import { Alert, KeyboardAvoidingView, Pressable, View } from "react-native"; +import { + Alert, + KeyboardAvoidingView, + Pressable, + ScrollView, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { z } from "zod"; import { Button } from "@/components/Button"; @@ -30,7 +36,7 @@ const CredentialsSchema = z.object({ username: z.string().min(1, t("login.username_required")), }); -const TVLogin: React.FC = () => { +export const TVLogin: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); @@ -322,11 +328,22 @@ const TVLogin: React.FC = () => { {api?.basePath ? ( // ==================== CREDENTIALS SCREEN ==================== - {/* Back Button */} { {api.basePath} - {/* Username Input */} - + {/* Username Input - extra padding for focus scale */} + { {/* Password Input */} - + { {/* Save Account Toggle */} - + { {t("login.quick_connect")} - + ) : ( // ==================== SERVER SELECTION SCREEN ==================== - {/* Logo */} @@ -472,14 +500,14 @@ const TVLogin: React.FC = () => { fontSize: 20, color: "#9CA3AF", textAlign: "center", - marginBottom: 32, + marginBottom: 40, }} > {t("server.enter_url_to_jellyfin_server")} - {/* Server URL Input */} - + {/* Server URL Input - extra padding for focus scale */} + { {/* Connect Button */} - + - {/* Server Discovery */} - - - - - {/* Discovered Servers */} - {discoveredServers.length > 0 && ( - - - {t("server.servers")} - - - {discoveredServers.map((server) => ( - { - setServerURL(server.address); - if (server.serverName) { - setServerName(server.serverName); - } - handleConnect(server.address); - }} - /> - ))} - - - )} - {/* Previous Servers */} { onPasswordRequired={handlePasswordRequired} onServerAction={handleServerAction} loginServerOverride={loginTriggerServer} + disabled={isAnyModalOpen} /> @@ -709,7 +663,7 @@ export const TVLogin: React.FC = () => { {/* Save Account Modal */} - { setShowSaveModal(false); @@ -720,7 +674,7 @@ export const TVLogin: React.FC = () => { /> {/* PIN Entry Modal */} - { setPinModalVisible(false); @@ -735,7 +689,7 @@ export const TVLogin: React.FC = () => { /> {/* Password Entry Modal */} - { setPasswordModalVisible(false); diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx new file mode 100644 index 000000000..25d9ce741 --- /dev/null +++ b/components/login/TVPINEntryModal.tsx @@ -0,0 +1,327 @@ +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Animated, + Easing, + Pressable, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; +import { useTVFocusAnimation } from "@/components/tv"; +import { verifyAccountPIN } from "@/utils/secureCredentials"; + +interface TVPINEntryModalProps { + visible: boolean; + onClose: () => void; + onSuccess: () => void; + onForgotPIN?: () => void; + serverUrl: string; + userId: string; + username: string; +} + +// Forgot PIN Button +const TVForgotPINButton: React.FC<{ + onPress: () => void; + label: string; + hasTVPreferredFocus?: boolean; +}> = ({ onPress, label, hasTVPreferredFocus = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + {label} + + + + ); +}; + +export const TVPINEntryModal: React.FC = ({ + visible, + onClose, + onSuccess, + onForgotPIN, + serverUrl, + userId, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [pinCode, setPinCode] = useState(""); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const pinInputRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + const shakeAnimation = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + // Reset state when opening + setPinCode(""); + setError(null); + setIsVerifying(false); + + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + useEffect(() => { + if (visible && isReady) { + const timer = setTimeout(() => { + pinInputRef.current?.focus(); + }, 150); + return () => clearTimeout(timer); + } + }, [visible, isReady]); + + const shake = () => { + Animated.sequence([ + Animated.timing(shakeAnimation, { + toValue: 15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: -15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 0, + duration: 50, + useNativeDriver: true, + }), + ]).start(); + }; + + const handlePinChange = async (value: string) => { + setPinCode(value); + setError(null); + + // Auto-verify when 4 digits entered + if (value.length === 4) { + setIsVerifying(true); + try { + const isValid = await verifyAccountPIN(serverUrl, userId, value); + if (isValid) { + onSuccess(); + setPinCode(""); + } else { + setError(t("pin.invalid_pin")); + shake(); + setPinCode(""); + } + } catch { + setError(t("pin.invalid_pin")); + shake(); + setPinCode(""); + } finally { + setIsVerifying(false); + } + } + }; + + const handleForgotPIN = () => { + Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.continue"), + style: "destructive", + onPress: () => { + onClose(); + onForgotPIN?.(); + }, + }, + ]); + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + {t("pin.enter_pin")} + + {t("pin.enter_pin_for", { username })} + + + + {/* PIN Input */} + {isReady && ( + + + {error && {error}} + {isVerifying && ( + + {t("common.verifying")} + + )} + + )} + + {/* Forgot PIN */} + {isReady && onForgotPIN && ( + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + header: { + paddingHorizontal: 48, + marginBottom: 24, + }, + title: { + fontSize: 28, + fontWeight: "bold", + color: "#fff", + marginBottom: 4, + }, + subtitle: { + fontSize: 16, + color: "rgba(255,255,255,0.6)", + }, + pinContainer: { + paddingHorizontal: 48, + alignItems: "center", + marginBottom: 16, + }, + errorText: { + color: "#ef4444", + fontSize: 14, + marginTop: 16, + textAlign: "center", + }, + verifyingText: { + color: "rgba(255,255,255,0.6)", + fontSize: 14, + marginTop: 16, + textAlign: "center", + }, + forgotContainer: { + alignItems: "center", + }, +}); diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx new file mode 100644 index 000000000..1473cf861 --- /dev/null +++ b/components/login/TVPasswordEntryModal.tsx @@ -0,0 +1,337 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Animated, + Easing, + Pressable, + StyleSheet, + TextInput, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv"; + +interface TVPasswordEntryModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (password: string) => Promise; + username: string; +} + +// TV Submit Button +const TVSubmitButton: React.FC<{ + onPress: () => void; + label: string; + loading?: boolean; + disabled?: boolean; +}> = ({ onPress, label, loading = false, disabled = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + const isDisabled = disabled || loading; + + return ( + + + {loading ? ( + + ) : ( + <> + + + {label} + + + )} + + + ); +}; + +// TV Focusable Password Input +const TVPasswordInput: React.FC<{ + value: string; + onChangeText: (text: string) => void; + placeholder: string; + onSubmitEditing: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ + value, + onChangeText, + placeholder, + onSubmitEditing, + hasTVPreferredFocus, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); + const inputRef = useRef(null); + + return ( + inputRef.current?.focus()} + onFocus={() => { + handleFocus(); + inputRef.current?.focus(); + }} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + + ); +}; + +export const TVPasswordEntryModal: React.FC = ({ + visible, + onClose, + onSubmit, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + // Reset state when opening + setPassword(""); + setError(null); + setIsLoading(false); + + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + const handleSubmit = async () => { + if (!password) { + setError(t("password.enter_password")); + return; + } + + setIsLoading(true); + setError(null); + + try { + await onSubmit(password); + setPassword(""); + } catch { + setError(t("password.invalid_password")); + } finally { + setIsLoading(false); + } + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + {t("password.enter_password")} + + {t("password.enter_password_for", { username })} + + + + {/* Password Input */} + {isReady && ( + + {t("login.password")} + { + setPassword(text); + setError(null); + }} + placeholder={t("login.password")} + onSubmitEditing={handleSubmit} + hasTVPreferredFocus + /> + {error && {error}} + + )} + + {/* Submit Button */} + {isReady && ( + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + header: { + paddingHorizontal: 48, + marginBottom: 24, + }, + title: { + fontSize: 28, + fontWeight: "bold", + color: "#fff", + marginBottom: 4, + }, + subtitle: { + fontSize: 16, + color: "rgba(255,255,255,0.6)", + }, + inputContainer: { + paddingHorizontal: 48, + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + color: "rgba(255,255,255,0.6)", + marginBottom: 8, + }, + errorText: { + color: "#ef4444", + fontSize: 14, + marginTop: 8, + }, + buttonContainer: { + paddingHorizontal: 48, + alignItems: "flex-start", + }, +}); diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index 8105db0ef..1903709ea 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -223,6 +223,8 @@ interface TVPreviousServersListProps { onServerAction?: (server: SavedServer) => void; // Called by parent when "Login" is selected from action sheet loginServerOverride?: SavedServer | null; + // Disable all focusable elements (when a modal is open) + disabled?: boolean; } // Export the action sheet for use in parent components @@ -236,6 +238,7 @@ export const TVPreviousServersList: React.FC = ({ onPasswordRequired, onServerAction, loginServerOverride, + disabled = false, }) => { const { t } = useTranslation(); const [_previousServers, setPreviousServers] = @@ -416,6 +419,7 @@ export const TVPreviousServersList: React.FC = ({ securityIcon={getSecurityIcon(server)} isLoading={loadingServer === server.address} onPress={() => handleServerPress(server)} + disabled={disabled} /> ))} diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx new file mode 100644 index 000000000..a1c7d55c0 --- /dev/null +++ b/components/login/TVSaveAccountModal.tsx @@ -0,0 +1,435 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + Pressable, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; +import { TVOptionCard, useTVFocusAnimation } from "@/components/tv"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; + +interface TVSaveAccountModalProps { + visible: boolean; + onClose: () => void; + onSave: (securityType: AccountSecurityType, pinCode?: string) => void; + username: string; +} + +interface SecurityOption { + type: AccountSecurityType; + titleKey: string; + descriptionKey: string; + icon: keyof typeof Ionicons.glyphMap; +} + +const SECURITY_OPTIONS: SecurityOption[] = [ + { + type: "none", + titleKey: "save_account.no_protection", + descriptionKey: "save_account.no_protection_desc", + icon: "flash-outline", + }, + { + type: "pin", + titleKey: "save_account.pin_code", + descriptionKey: "save_account.pin_code_desc", + icon: "keypad-outline", + }, + { + type: "password", + titleKey: "save_account.password", + descriptionKey: "save_account.password_desc", + icon: "lock-closed-outline", + }, +]; + +// Custom Save Button with TV focus +const TVSaveButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; + hasTVPreferredFocus?: boolean; +}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; + +// Back Button for PIN step +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + hasTVPreferredFocus?: boolean; +}> = ({ onPress, label, hasTVPreferredFocus = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; + +export const TVSaveAccountModal: React.FC = ({ + visible, + onClose, + onSave, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [step, setStep] = useState<"select" | "pin">("select"); + const [selectedType, setSelectedType] = useState("none"); + const [pinCode, setPinCode] = useState(""); + const [pinError, setPinError] = useState(null); + const pinInputRef = useRef(null); + + // Use useState for focus tracking (per TV focus guide) + const [firstCardRef, setFirstCardRef] = useState(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + // Reset state when opening + setStep("select"); + setSelectedType("none"); + setPinCode(""); + setPinError(null); + + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + // Focus the first card when ready + useEffect(() => { + if (isReady && firstCardRef && step === "select") { + const timer = setTimeout(() => { + (firstCardRef as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady, firstCardRef, step]); + + useEffect(() => { + if (step === "pin" && isReady) { + const timer = setTimeout(() => { + pinInputRef.current?.focus(); + }, 150); + return () => clearTimeout(timer); + } + }, [step, isReady]); + + const handleOptionSelect = (type: AccountSecurityType) => { + setSelectedType(type); + if (type === "pin") { + setStep("pin"); + setPinCode(""); + setPinError(null); + } else { + // For "none" or "password", save immediately + onSave(type); + resetAndClose(); + } + }; + + const handlePinSave = () => { + if (pinCode.length !== 4) { + setPinError(t("pin.enter_4_digits")); + return; + } + onSave("pin", pinCode); + resetAndClose(); + }; + + const handleBack = () => { + setStep("select"); + setPinCode(""); + setPinError(null); + }; + + const resetAndClose = () => { + setStep("select"); + setSelectedType("none"); + setPinCode(""); + setPinError(null); + onClose(); + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + {t("save_account.title")} + {username} + + + {step === "select" ? ( + // Security selection step + <> + + {t("save_account.security_option")} + + {isReady && ( + + {SECURITY_OPTIONS.map((option, index) => ( + handleOptionSelect(option.type)} + width={220} + height={100} + /> + ))} + + )} + + ) : ( + // PIN entry step + <> + {t("pin.setup_pin")} + + { + setPinCode(text); + setPinError(null); + }} + length={4} + autoFocus + /> + {pinError && {pinError}} + + + {isReady && ( + + + + + )} + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + header: { + paddingHorizontal: 48, + marginBottom: 20, + }, + title: { + fontSize: 28, + fontWeight: "bold", + color: "#fff", + marginBottom: 4, + }, + subtitle: { + fontSize: 16, + color: "rgba(255,255,255,0.6)", + }, + sectionTitle: { + fontSize: 16, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 10, + gap: 12, + }, + buttonRow: { + marginTop: 20, + paddingHorizontal: 48, + flexDirection: "row", + gap: 16, + alignItems: "center", + }, + pinContainer: { + paddingHorizontal: 48, + alignItems: "center", + marginBottom: 10, + }, + errorText: { + color: "#ef4444", + fontSize: 14, + marginTop: 12, + textAlign: "center", + }, +}); diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx index 8c6a9d1d3..85ccc3f1d 100644 --- a/components/login/TVSaveAccountToggle.tsx +++ b/components/login/TVSaveAccountToggle.tsx @@ -8,6 +8,7 @@ interface TVSaveAccountToggleProps { onValueChange: (value: boolean) => void; label: string; hasTVPreferredFocus?: boolean; + disabled?: boolean; } export const TVSaveAccountToggle: React.FC = ({ @@ -15,6 +16,7 @@ export const TVSaveAccountToggle: React.FC = ({ onValueChange, label, hasTVPreferredFocus, + disabled = false, }) => { const [isFocused, setIsFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -52,7 +54,9 @@ export const TVSaveAccountToggle: React.FC = ({ onPress={() => onValueChange(!value)} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={hasTVPreferredFocus} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} > void; hasTVPreferredFocus?: boolean; + disabled?: boolean; } export const TVServerCard: React.FC = ({ @@ -26,6 +27,7 @@ export const TVServerCard: React.FC = ({ isLoading, onPress, hasTVPreferredFocus, + disabled = false, }) => { const [isFocused, setIsFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -58,13 +60,16 @@ export const TVServerCard: React.FC = ({ animateFocus(false); }; + const isDisabled = disabled || isLoading; + return ( = ({ }} /> )} - {!episode.UserData?.Played && } + diff --git a/docs/research/hdr-mpv.md b/docs/research/hdr-mpv.md index f2e114a6f..6061e026d 100644 --- a/docs/research/hdr-mpv.md +++ b/docs/research/hdr-mpv.md @@ -4,11 +4,13 @@ HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse. -## Current Implementation +**Key Discovery:** HDR works correctly on iPhone but not on tvOS, despite using the same mpv player. -Streamyfin uses MPVKit with the `vo_avfoundation` video output driver, which renders video to `AVSampleBufferDisplayLayer`. This enables Picture-in-Picture (PiP) support but has HDR limitations. +--- -**Current code in `MpvPlayerView.swift`:** +## Why HDR Works on iPhone + +In `MpvPlayerView.swift`: ```swift #if !os(tvOS) if #available(iOS 17.0, *) { @@ -17,141 +19,392 @@ if #available(iOS 17.0, *) { #endif ``` -The tvOS exclusion was intentional because `wantsExtendedDynamicRangeContent` was believed to be iOS-only, but this may not be accurate for tvOS 17.0+. +On iOS 17+, setting `wantsExtendedDynamicRangeContent = true` on `AVSampleBufferDisplayLayer` enables Extended Dynamic Range (EDR). This tells the display layer to preserve HDR metadata and render in high dynamic range. + +**This API does not exist on tvOS.** Attempting to use it results in: +> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS + +tvOS uses a different HDR architecture designed for external displays via HDMI. --- -## Research Findings +## tvOS HDR Architecture -### 1. This is a Known Industry-Wide Limitation +Unlike iPhone (integrated display), Apple TV connects to external TVs. Apple expects apps to: -The same HDR issue exists in multiple projects: +1. **Use `AVDisplayCriteria`** to request display mode changes +2. **Attach proper colorspace metadata** to pixel buffers +3. **Let the TV handle HDR rendering** via HDMI passthrough + +This is how Netflix, Infuse, and the TV app work - they signal "I'm playing HDR10 at 24fps" and tvOS switches the TV to that mode. + +--- + +## MPVKit vo_avfoundation Analysis + +**Location:** `/MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +### Existing HDR Infrastructure + +The driver has comprehensive HDR support already built in: + +#### 1. HDR Metadata Copy Function (lines 253-270) +```c +static void copy_hdr_metadata(CVPixelBufferRef src, CVPixelBufferRef dst) +{ + const CFStringRef keys[] = { + kCVImageBufferTransferFunctionKey, // PQ for HDR10, HLG for HLG + kCVImageBufferColorPrimariesKey, // BT.2020 for HDR + kCVImageBufferYCbCrMatrixKey, + kCVImageBufferMasteringDisplayColorVolumeKey, // HDR10 static metadata + kCVImageBufferContentLightLevelInfoKey, // MaxCLL, MaxFALL + }; + + for (size_t i = 0; i < MP_ARRAY_SIZE(keys); i++) { + CFTypeRef value = CVBufferGetAttachment(src, keys[i], NULL); + if (value) { + CVBufferSetAttachment(dst, keys[i], value, kCVAttachmentMode_ShouldPropagate); + } + } +} +``` + +#### 2. 10-bit HDR Format Support (lines 232-247) +```c +// For 10-bit HDR content (P010), use RGBA half-float to preserve HDR precision +if (format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange || + format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) { + outputFormat = kCVPixelFormatType_64RGBAHalf; +} +``` + +#### 3. HDR-Safe GPU Compositing (lines 694-695) +```c +CGColorSpaceRef workingColorSpace = CGColorSpaceCreateWithName( + kCGColorSpaceExtendedLinearDisplayP3); +``` + +### The Problem: Metadata Not Attached in Main Code Path + +**Critical Finding:** `copy_hdr_metadata()` is only called during OSD compositing (line 609-610): + +```c +// In composite mode, render OSD and composite onto frame +if (p->composite_osd) { + render_osd(vo, pts); + CVPixelBufferRef composited = composite_frame(vo, pixbuf); + // copy_hdr_metadata() called inside composite_frame() +} +``` + +If `composite_osd` is false (default), **HDR metadata is never attached**. + +### Frame Flow Analysis + +``` +draw_frame() called + │ + ├─► Hardware decoded (IMGFMT_VIDEOTOOLBOX) + │ └─► pixbuf = mpi->planes[3] // Direct from VideoToolbox + │ └─► Metadata SHOULD be attached by decoder, but not verified + │ + └─► Software decoded (NV12, 420P, P010) + └─► upload_software_frame() + └─► Creates new CVPixelBuffer + └─► Copies pixel data only + └─► ❌ NO colorspace metadata attached! + + ▼ + CMVideoFormatDescriptionCreateForImageBuffer(finalBuffer) + └─► Format description created FROM pixel buffer + └─► If buffer lacks HDR metadata, format won't have it + + ▼ + [displayLayer enqueueSampleBuffer:buf] + └─► Sent to display layer without HDR signal +``` + +--- + +## Root Cause Summary + +| Issue | Impact | +|-------|--------| +| `wantsExtendedDynamicRangeContent` unavailable on tvOS | Can't use iOS EDR approach | +| `copy_hdr_metadata()` only runs during OSD compositing | Main playback path skips HDR metadata | +| Software decoded frames get no colorspace attachments | mpv knows colorspace but doesn't pass it to pixel buffer | +| VideoToolbox metadata not verified | May or may not have HDR attachments | + +--- + +## mp_image Colorspace Structures + +mpv uses libplacebo's colorspace structures. Here's how colorspace info flows: + +### Structure Hierarchy + +``` +mp_image (video/mp_image.h) + └─► params: mp_image_params + └─► color: pl_color_space + │ ├─► primaries: pl_color_primaries (BT.2020, etc.) + │ ├─► transfer: pl_color_transfer (PQ, HLG, etc.) + │ └─► hdr: pl_hdr_metadata (MaxCLL, MaxFALL, etc.) + └─► repr: pl_color_repr + ├─► sys: pl_color_system (BT.2100_PQ, etc.) + └─► levels: pl_color_levels (TV/Full range) +``` + +### Key Enums + +#### Color Primaries (`enum pl_color_primaries`) +```c +PL_COLOR_PRIM_UNKNOWN = 0, +PL_COLOR_PRIM_BT_709, // HD/SDR standard +PL_COLOR_PRIM_BT_2020, // UHD/HDR wide gamut ← HDR +PL_COLOR_PRIM_DCI_P3, // DCI P3 (cinema) +PL_COLOR_PRIM_DISPLAY_P3, // Display P3 (Apple) +// ... more +``` + +#### Transfer Functions (`enum pl_color_transfer`) +```c +PL_COLOR_TRC_UNKNOWN = 0, +PL_COLOR_TRC_BT_1886, // SDR gamma +PL_COLOR_TRC_SRGB, // sRGB +PL_COLOR_TRC_PQ, // SMPTE 2084 PQ (HDR10/DolbyVision) ← HDR +PL_COLOR_TRC_HLG, // ITU-R BT.2100 HLG ← HDR +// ... more +``` + +#### Color Systems (`enum pl_color_system`) +```c +PL_COLOR_SYSTEM_BT_709, // HD/SDR +PL_COLOR_SYSTEM_BT_2020_NC, // UHD (non-constant luminance) +PL_COLOR_SYSTEM_BT_2100_PQ, // HDR10 ← HDR +PL_COLOR_SYSTEM_BT_2100_HLG, // HLG ← HDR +PL_COLOR_SYSTEM_DOLBYVISION, // Dolby Vision ← HDR +// ... more +``` + +### HDR Metadata Structure (`struct pl_hdr_metadata`) +```c +struct pl_hdr_metadata { + struct pl_raw_primaries prim; // CIE xy primaries + float min_luma, max_luma; // Luminance range (cd/m²) + float max_cll; // Maximum Content Light Level + float max_fall; // Maximum Frame-Average Light Level + // ... more +}; +``` + +### Accessing Colorspace in vo_avfoundation + +```c +// In draw_frame(): +struct mp_image *mpi = frame->current; + +// Color primaries +enum pl_color_primaries prim = mpi->params.color.primaries; + +// Transfer function +enum pl_color_transfer trc = mpi->params.color.transfer; + +// HDR metadata +struct pl_hdr_metadata hdr = mpi->params.color.hdr; + +// HDR detection +bool is_hdr = (trc == PL_COLOR_TRC_PQ || trc == PL_COLOR_TRC_HLG); +bool is_wide_gamut = (prim == PL_COLOR_PRIM_BT_2020); +``` + +--- + +## The Fix + +### Required Changes in vo_avfoundation + +**File:** `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +**Location:** After line 821 in `draw_frame()`, before the sample buffer is created. + +#### Add Required Include +```c +#include "video/csputils.h" // For pl_color_* enums (if not already included) +``` + +#### Add HDR Metadata Attachment Function +```c +// Add after copy_hdr_metadata() function (around line 270) +static void attach_hdr_metadata(struct vo *vo, CVPixelBufferRef pixbuf, + struct mp_image *mpi) +{ + enum pl_color_primaries prim = mpi->params.color.primaries; + enum pl_color_transfer trc = mpi->params.color.transfer; + + // Attach BT.2020 color primaries (HDR wide color gamut) + if (prim == PL_COLOR_PRIM_BT_2020) { + CVBufferSetAttachment(pixbuf, kCVImageBufferColorPrimariesKey, + kCVImageBufferColorPrimaries_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + CVBufferSetAttachment(pixbuf, kCVImageBufferYCbCrMatrixKey, + kCVImageBufferYCbCrMatrix_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached BT.2020 color primaries\n"); + } + + // Attach PQ transfer function (HDR10/Dolby Vision) + if (trc == PL_COLOR_TRC_PQ) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached PQ transfer function (HDR10)\n"); + } + // Attach HLG transfer function + else if (trc == PL_COLOR_TRC_HLG) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_ITU_R_2100_HLG, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached HLG transfer function\n"); + } + + // Attach HDR static metadata if available + struct pl_hdr_metadata hdr = mpi->params.color.hdr; + if (hdr.max_cll > 0 || hdr.max_fall > 0) { + // ContentLightLevelInfo is a 4-byte structure: + // - 2 bytes: MaxCLL (max content light level) + // - 2 bytes: MaxFALL (max frame-average light level) + uint16_t cll_data[2] = { + (uint16_t)fminf(hdr.max_cll, 65535.0f), + (uint16_t)fminf(hdr.max_fall, 65535.0f) + }; + + CFDataRef cllInfo = CFDataCreate(NULL, (const UInt8 *)cll_data, sizeof(cll_data)); + if (cllInfo) { + CVBufferSetAttachment(pixbuf, kCVImageBufferContentLightLevelInfoKey, + cllInfo, kCVAttachmentMode_ShouldPropagate); + CFRelease(cllInfo); + + MP_VERBOSE(vo, "HDR: Attached CLL metadata (MaxCLL=%d, MaxFALL=%d)\n", + cll_data[0], cll_data[1]); + } + } +} +``` + +#### Call the Function in draw_frame() + +```c +// In draw_frame(), after line 821 (after getting pixbuf), add: + +// Attach HDR colorspace metadata to pixel buffer +// This ensures the display layer receives proper HDR signaling +attach_hdr_metadata(vo, pixbuf, mpi); +``` + +### Complete draw_frame() Modification + +The modified section should look like: + +```c +CVPixelBufferRef pixbuf = NULL; +bool pixbufNeedsRelease = false; + +// Handle different input formats +if (mpi->imgfmt == IMGFMT_VIDEOTOOLBOX) { + // Hardware decoded: zero-copy passthrough + pixbuf = (CVPixelBufferRef)mpi->planes[3]; +} else { + // Software decoded: upload to CVPixelBuffer + pixbuf = upload_software_frame(vo, mpi); + if (!pixbuf) { + MP_ERR(vo, "Failed to upload software frame\n"); + mp_image_unrefp(&mpi); + return false; + } + pixbufNeedsRelease = true; +} + +// >>> NEW: Attach HDR colorspace metadata <<< +attach_hdr_metadata(vo, pixbuf, mpi); + +CVPixelBufferRef finalBuffer = pixbuf; +bool needsRelease = false; +// ... rest of the function +``` + +--- + +## Alternative Solutions + +### Option A: Enable composite_osd Mode (Quick Test) + +Since `copy_hdr_metadata()` works in composite mode, try enabling it: +``` +--avfoundation-composite-osd=yes +``` + +This would trigger the existing HDR metadata path. Downside: OSD compositing has performance overhead. + +### Option B: Full vo_avfoundation Fix (Recommended) + +Modify the driver to always attach colorspace metadata based on `mp_image` params. This is the implementation described above. + +### Option C: Dual Player Approach + +Use AVPlayer for HDR content, mpv for everything else. This is what Swiftfin does. + +--- + +## Implementation Checklist + +- [ ] Clone MPVKit fork +- [ ] Modify `0004-avfoundation-video-output.patch`: + - [ ] Add `attach_hdr_metadata()` function + - [ ] Call it in `draw_frame()` after getting pixbuf + - [ ] Add necessary includes if needed +- [ ] Rebuild MPVKit +- [ ] Test with HDR10 content on tvOS +- [ ] Verify TV shows HDR indicator +- [ ] Test with HLG content +- [ ] Test with Dolby Vision content (may need additional work) + +--- + +## Current Implementation Status + +**What's implemented in Streamyfin:** + +1. **HDR Detection** (`MPVLayerRenderer.swift`) + - Reads `video-params/primaries` and `video-params/gamma` from mpv + - Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision + +2. **AVDisplayCriteria** (`MpvPlayerView.swift`) + - Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected + - Creates CMFormatDescription with HDR color extensions + +3. **target-colorspace-hint** (`MPVLayerRenderer.swift`) + - Added `target-colorspace-hint=yes` for tvOS + +**What's NOT working:** +- TV doesn't show HDR indicator +- Colors appear washed out +- The pixel buffers lack HDR metadata attachments ← **This is what the fix addresses** + +--- + +## Industry Context | Project | Player | HDR Status | |---------|--------|------------| -| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, no HDR signal | -| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv (Enhanced Player) | No HDR support | +| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR | +| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support | | Infuse | Custom Metal engine | Works correctly | -**Key quote from mpv maintainer** ([issue #9633](https://github.com/mpv-player/mpv/issues/9633)): -> "mpv doesn't support metal at all (likely won't ever)" - -### 2. Why Infuse Works - -Infuse uses a **custom Metal-based video rendering engine** built from scratch - not mpv, not VLCKit, not AVPlayer. This allows them to properly handle HDR passthrough with the correct pixel formats and color spaces. - -Source: [Firecore Community](https://community.firecore.com/t/what-player-does-infuse-use/38003) - -### 3. Swiftfin's Solution - -Swiftfin offers two players: -- **VLCKit player** (default) - No HDR support -- **Native player (AVPlayer)** - HDR works correctly - -When using the native player with proper stream configuration (HEVC `hvc1`, fMP4-HLS), Apple TV correctly switches to HDR mode. - -Source: [Swiftfin Issue #331](https://github.com/jellyfin/Swiftfin/issues/331) - -### 4. Apple's HDR Requirements - -According to [WWDC22: Display HDR video in EDR with AVFoundation and Metal](https://developer.apple.com/videos/play/wwdc2022/110565/): - -**Required for EDR (Extended Dynamic Range):** -```swift -// On CAMetalLayer -layer.wantsExtendedDynamicRangeContent = true -layer.pixelFormat = MTLPixelFormatRGBA16Float -layer.colorspace = kCGColorSpaceExtendedLinearDisplayP3 -``` - -**For AVPlayerItemVideoOutput:** -```swift -let videoColorProperties = [ - AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65, - AVVideoTransferFunctionKey: AVVideoTransferFunction_Linear, - AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020 -] -``` - -### 5. CVPixelBuffer HDR Metadata - -For pixel buffers to be recognized as HDR, they need colorspace attachments: - -```c -CVBufferSetAttachment(pixelBuffer, kCVImageBufferColorPrimariesKey, - kCVImageBufferColorPrimaries_ITU_R_2020, - kCVAttachmentMode_ShouldPropagate); -CVBufferSetAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey, - kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, // HDR10 - kCVAttachmentMode_ShouldPropagate); -CVBufferSetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey, - kCVImageBufferYCbCrMatrix_ITU_R_2020, - kCVAttachmentMode_ShouldPropagate); -``` - -### 6. macOS EDR Research - -From [mpv issue #7341](https://github.com/mpv-player/mpv/issues/7341), testing showed: -- **mpv playback**: `maximumExtendedDynamicRangeColorComponentValue: 1.0` -- **QuickTime playback**: `maximumExtendedDynamicRangeColorComponentValue: 2.0` - -QuickTime uses `CAOpenGLLayer.wantsExtendedDynamicRangeContent` to enable EDR. - -### 7. iOS/tvOS OpenGL Limitations - -From [mpv issue #8467](https://github.com/mpv-player/mpv/issues/8467): -> "Disabling HDR peak computation (one or more of the following is not supported: compute shaders=0, SSBO=0)" - -Apple's OpenGL implementation lacks compute shader and SSBO support, making traditional HDR peak computation impossible. - ---- - -## Potential Solutions - -### ~~Option A: Enable EDR on tvOS (Quick Test)~~ - RULED OUT - -**Status:** Tested and failed - `wantsExtendedDynamicRangeContent` is **not available on tvOS**. - -The API is iOS-only. Attempting to use it on tvOS results in: -> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS - -This confirms tvOS requires a different approach for HDR. - -### Option B: Modify vo_avfoundation in MPVKit - -Add HDR colorspace metadata when creating CVPixelBuffers. - -**Location:** `streamyfin/MPVKit` fork, vo_avfoundation driver - -**Required changes:** -1. Detect HDR content (check video color primaries/transfer function) -2. Attach colorspace metadata to pixel buffers -3. Possibly configure AVSampleBufferDisplayLayer for HDR - -**Pros:** Fixes the root cause -**Cons:** Requires modifying and rebuilding MPVKit - -### Option C: Dual Player Approach (Like Swiftfin) - -Implement AVPlayer-based playback for HDR content, keep mpv for everything else. - -**Pros:** Proven solution, full HDR/DV support -**Cons:** Significant development effort, two player implementations to maintain - -### Option D: Accept Limitation - -Document that HDR passthrough is not supported with the mpv player on tvOS. - -**Pros:** No development work -**Cons:** Poor user experience for HDR content - ---- - -## Recommended Approach - -1. **First:** Try Option A (enable EDR on tvOS) - simple test -2. **If that fails:** Investigate Option B (modify vo_avfoundation in MPVKit) -3. **Long-term:** Consider Option C (dual player) for full HDR support +**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first. --- @@ -161,60 +414,23 @@ Document that HDR passthrough is not supported with the mpv player on tvOS. - [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager) - [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria) - [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/) -- [WWDC20: Edit and play back HDR video](https://developer.apple.com/videos/play/wwdc2020/10009/) -### Related Projects -- [Swiftfin HDR Issues](https://github.com/jellyfin/Swiftfin/issues/811) -- [Swiftfin Match Content](https://github.com/jellyfin/Swiftfin/issues/331) -- [mpv HDR on iOS](https://github.com/mpv-player/mpv/issues/9633) -- [mpv macOS EDR](https://github.com/mpv-player/mpv/issues/7341) -- [mpv HDR passthrough](https://github.com/mpv-player/mpv/issues/11812) +### CVImageBuffer Keys +- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3) +- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG) +- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix +- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata +- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL -### Articles -- [Plex's mpv Player](https://freetime.mikeconnelly.com/archives/8360) -- [Rendering HDR Video with AVFoundation and Metal](https://metalbyexample.com/hdr-video/) +### mpv/libplacebo Source +- mp_image struct: `video/mp_image.h` +- Colorspace enums: libplacebo `pl_color.h` +- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` ---- - -## Current Implementation Status - -**What we've implemented so far:** - -1. **HDR Detection** (`MPVLayerRenderer.swift`) - - Reads `video-params/primaries` and `video-params/gamma` from mpv - - Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision - - Logs: `HDR Detection - primaries: bt.2020, gamma: pq, fps: 23.976` - -2. **AVDisplayCriteria** (`MpvPlayerView.swift`) - - Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected - - Creates CMFormatDescription with proper HDR color extensions - - Logs: `🎬 HDR: Setting display criteria to hdr10, fps: 23.976` - -3. **target-colorspace-hint** (`MPVLayerRenderer.swift`) - - Added `target-colorspace-hint=yes` for tvOS to signal colorspace to display - -**What's NOT working:** -- TV doesn't show HDR indicator -- Colors still appear washed out -- The display mode switch may not be happening - ---- - -## Next Steps for Investigation - -1. **Verify AVDisplayCriteria is being honored:** - - Check if Apple TV settings allow app-requested display mode changes - - Verify the CMFormatDescription is correctly formed - -2. **Examine vo_avfoundation pixel buffer creation:** - - Clone MPVKit source - - Find where CVPixelBuffers are created - - Check if colorspace attachments are being set - -3. **Test with AVSampleBufferDisplayLayer debugging:** - - Log pixel buffer attachments - - Verify layer configuration - -4. **Consider testing with VLCKit:** - - Swiftfin's VLCKit has same issue - - Their solution: use AVPlayer for HDR content +### Key Functions in vo_avfoundation +| Function | Line | Purpose | +|----------|------|---------| +| `draw_frame()` | 781 | Main frame rendering | +| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers | +| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer | +| `composite_frame()` | 582 | OSD compositing with HDR support | diff --git a/translations/sv.json b/translations/sv.json index 779fa8421..e6db40046 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -334,7 +334,7 @@ "server_url_placeholder": "Seerr URL", "password": "Lösenord", "password_placeholder": "Ange lösenord för Jellyfin användare {{username}}", - "login_button": "Login", + "login_button": "Logga in", "total_media_requests": "Totalt antal mediaförfrågningar", "movie_quota_limit": "Gräns för filmkvot", "movie_quota_days": "Filmkvot Dagar", From eeb4ef30082fe9f287369f98513abe0c26bf4063 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 067/309] feat(tv): split actor filmography into movies and series sections --- components/persons/TVActorPage.tsx | 191 +++++++++++++++++++++-------- 1 file changed, 137 insertions(+), 54 deletions(-) diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index 879106c93..104c3d184 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -107,10 +107,7 @@ export const TVActorPage: React.FC = ({ personId }) => { const [user] = useAtom(userAtom); // Track which filmography item is currently focused for dynamic backdrop - const [focusedIndex, setFocusedIndex] = useState(0); - - // FlatList ref for scrolling back - const filmographyListRef = useRef>(null); + const [focusedItem, setFocusedItem] = useState(null); // Fetch actor details const { data: item, isLoading: isLoadingActor } = useQuery({ @@ -125,9 +122,9 @@ export const TVActorPage: React.FC = ({ personId }) => { staleTime: 60, }); - // Fetch filmography - const { data: filmography = [], isLoading: isLoadingFilmography } = useQuery({ - queryKey: ["actor", "filmography", personId], + // Fetch movies + const { data: movies = [], isLoading: isLoadingMovies } = useQuery({ + queryKey: ["actor", "movies", personId], queryFn: async () => { if (!api || !user?.Id) return []; @@ -137,7 +134,32 @@ export const TVActorPage: React.FC = ({ personId }) => { startIndex: 0, limit: 20, sortOrder: ["Descending", "Descending", "Ascending"], - includeItemTypes: ["Movie", "Series"], + includeItemTypes: ["Movie"], + recursive: true, + fields: ["ParentId", "PrimaryImageAspectRatio"], + sortBy: ["PremiereDate", "ProductionYear", "SortName"], + collapseBoxSetItems: false, + }); + + return response.data.Items || []; + }, + enabled: !!personId && !!api && !!user?.Id, + staleTime: 60, + }); + + // Fetch series + const { data: series = [], isLoading: isLoadingSeries } = useQuery({ + queryKey: ["actor", "series", personId], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + personIds: [personId], + startIndex: 0, + limit: 20, + sortOrder: ["Descending", "Descending", "Ascending"], + includeItemTypes: ["Series"], recursive: true, fields: ["ParentId", "PrimaryImageAspectRatio"], sortBy: ["PremiereDate", "ProductionYear", "SortName"], @@ -153,15 +175,16 @@ export const TVActorPage: React.FC = ({ personId }) => { // Get backdrop URL from the currently focused filmography item // Changes dynamically as user navigates through the list const backdropUrl = useMemo(() => { - if (filmography.length === 0) return null; - const focusedItem = filmography[focusedIndex] ?? filmography[0]; + // Use focused item if available, otherwise fall back to first movie or series + const itemForBackdrop = focusedItem ?? movies[0] ?? series[0]; + if (!itemForBackdrop) return null; return getBackdropUrl({ api, - item: focusedItem, + item: itemForBackdrop, quality: 90, width: 1920, }); - }, [api, filmography, focusedIndex]); + }, [api, focusedItem, movies, series]); // Crossfade animation for backdrop transitions // Use two alternating layers for smooth crossfade @@ -261,12 +284,15 @@ export const TVActorPage: React.FC = ({ personId }) => { // Render filmography item const renderFilmographyItem = useCallback( - ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( + ( + { item: filmItem, index }: { item: BaseItemDto; index: number }, + isFirstSection: boolean, + ) => ( handleItemPress(filmItem)} - onFocus={() => setFocusedIndex(index)} - hasTVPreferredFocus={index === 0} + onFocus={() => setFocusedItem(filmItem)} + hasTVPreferredFocus={isFirstSection && index === 0} > @@ -469,21 +495,10 @@ export const TVActorPage: React.FC = ({ personId }) => { - {/* Filmography section */} + {/* Filmography sections */} - - {t("item_card.appeared_in")} - - - {isLoadingFilmography ? ( + {/* Movies Section */} + {isLoadingMovies ? ( = ({ personId }) => { > - ) : filmography.length === 0 ? ( - 0 && ( + + + {t("item_card.movies")} + + filmItem.Id!} + renderItem={(props) => renderFilmographyItem(props, true)} + showsHorizontalScrollIndicator={false} + initialNumToRender={6} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + + ) + )} + + {/* Series Section */} + {isLoadingSeries ? ( + - {t("common.no_results")} - + + ) : ( - filmItem.Id!} - renderItem={renderFilmographyItem} - showsHorizontalScrollIndicator={false} - initialNumToRender={6} - maxToRenderPerBatch={4} - windowSize={5} - removeClippedSubviews={false} - getItemLayout={getItemLayout} - style={{ overflow: "visible" }} - contentContainerStyle={{ - paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, - }} - /> + series.length > 0 && ( + + + {t("item_card.shows")} + + filmItem.Id!} + renderItem={(props) => + renderFilmographyItem(props, movies.length === 0) + } + showsHorizontalScrollIndicator={false} + initialNumToRender={6} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + + ) )} + + {/* Empty state - only show if both sections are empty and not loading */} + {!isLoadingMovies && + !isLoadingSeries && + movies.length === 0 && + series.length === 0 && ( + + {t("common.no_results")} + + )} From 16a236393dccb91a69bdeee509c4ee3312f900c3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 068/309] refactor(tv): migrate series season selector to navigation-based modal pattern --- components/series/TVSeasonSelector.tsx | 195 ------------------------- components/series/TVSeriesPage.tsx | 42 +++--- 2 files changed, 24 insertions(+), 213 deletions(-) delete mode 100644 components/series/TVSeasonSelector.tsx diff --git a/components/series/TVSeasonSelector.tsx b/components/series/TVSeasonSelector.tsx deleted file mode 100644 index 947bc9187..000000000 --- a/components/series/TVSeasonSelector.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { BlurView } from "expo-blur"; -import React, { useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; -import { Text } from "@/components/common/Text"; - -interface TVSeasonSelectorProps { - visible: boolean; - seasons: BaseItemDto[]; - selectedSeasonIndex: number | string | null | undefined; - onSelect: (seasonIndex: number) => void; - onClose: () => void; -} - -const TVSeasonCard: React.FC<{ - season: BaseItemDto; - isSelected: boolean; - hasTVPreferredFocus?: boolean; - onPress: () => void; -}> = ({ season, isSelected, hasTVPreferredFocus, onPress }) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - const seasonName = useMemo(() => { - if (season.Name) return season.Name; - if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`; - return "Season"; - }, [season]); - - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {seasonName} - - {isSelected && !focused && ( - - - - )} - - - ); -}; - -export const TVSeasonSelector: React.FC = ({ - visible, - seasons, - selectedSeasonIndex, - onSelect, - onClose, -}) => { - const { t } = useTranslation(); - - const initialFocusIndex = useMemo(() => { - const idx = seasons.findIndex( - (s) => - s.IndexNumber === selectedSeasonIndex || - s.Name === String(selectedSeasonIndex), - ); - return idx >= 0 ? idx : 0; - }, [seasons, selectedSeasonIndex]); - - if (!visible) return null; - - return ( - - - - {/* Title */} - - {t("item_card.select_season")} - - - {/* Horizontal season cards */} - - {seasons.map((season, index) => ( - { - onSelect(season.IndexNumber ?? index); - onClose(); - }} - /> - ))} - - - - - ); -}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index bd8047a76..f393fe943 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -31,9 +31,9 @@ import { TV_EPISODE_WIDTH, TVEpisodeCard, } from "@/components/series/TVEpisodeCard"; -import { TVSeasonSelector } from "@/components/series/TVSeasonSelector"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; import useRouter from "@/hooks/useAppRouter"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; @@ -227,9 +227,8 @@ export const TVSeriesPage: React.FC = ({ [item.Id, seasonIndexState], ); - // Modal state - const [openModal, setOpenModal] = useState<"season" | null>(null); - const isModalOpen = openModal !== null; + // TV option modal hook + const { showOptions } = useTVOptionModal(); // ScrollView ref for page scrolling const mainScrollRef = useRef(null); @@ -400,6 +399,25 @@ export const TVSeriesPage: React.FC = ({ [item.Id, setSeasonIndexState], ); + // Open season modal + const handleOpenSeasonModal = useCallback(() => { + const options = seasons.map((season: BaseItemDto) => ({ + label: season.Name || `Season ${season.IndexNumber}`, + value: season.IndexNumber ?? 0, + selected: + season.IndexNumber === selectedSeasonIndex || + season.Name === String(selectedSeasonIndex), + })); + + showOptions({ + title: t("item_card.select_season"), + options, + onSelect: handleSeasonSelect, + cardWidth: 180, + cardHeight: 85, + }); + }, [seasons, selectedSeasonIndex, showOptions, t, handleSeasonSelect]); + // Episode list item layout const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ @@ -417,13 +435,12 @@ export const TVSeriesPage: React.FC = ({ handleEpisodePress(episode)} - disabled={isModalOpen} onFocus={handleEpisodeFocus} onBlur={handleEpisodeBlur} /> ), - [handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur], + [handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur], ); // Get play button text @@ -545,7 +562,6 @@ export const TVSeriesPage: React.FC = ({ = ({ {seasons.length > 1 && ( setOpenModal("season")} - disabled={isModalOpen} + onPress={handleOpenSeasonModal} /> )} @@ -621,15 +636,6 @@ export const TVSeriesPage: React.FC = ({ /> - - {/* Season selector modal */} - setOpenModal(null)} - /> ); }; From f4445c415241b7cd156abaceee15d8431de93a81 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 069/309] chore(i18n): add movies and shows translation keys for tv actor page --- translations/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/translations/en.json b/translations/en.json index 0cb517278..1aa4f114a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -686,6 +686,8 @@ "cast": "Cast", "technical_details": "Technical Details", "appeared_in": "Appeared In", + "movies": "Movies", + "shows": "Shows", "could_not_load_item": "Could Not Load Item", "none": "None", "download": { From 2b36d4bc76042508ddfbc2a0bb7d6edc1db169ef Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 070/309] fix(tv): font sizes --- components/Badge.tsx | 49 ++++++++-- components/GenreTags.tsx | 31 ++++++- components/ItemContent.tv.tsx | 51 +++++++---- components/home/Favorites.tv.tsx | 5 +- components/home/Home.tv.tsx | 9 +- .../InfiniteScrollingCollectionList.tv.tsx | 43 +++++++-- .../StreamystatsPromotedWatchlists.tv.tsx | 16 +++- .../home/StreamystatsRecommendations.tv.tsx | 16 +++- components/series/TVEpisodeCard.tsx | 21 +++-- components/series/TVSeriesHeader.tsx | 39 ++++++-- components/series/TVSeriesPage.tsx | 30 +++--- components/tv/TVActorCard.tsx | 21 +++-- components/tv/TVCancelButton.tsx | 3 +- components/tv/TVCastCrewText.tsx | 11 ++- components/tv/TVCastSection.tsx | 11 ++- components/tv/TVLanguageCard.tsx | 5 +- components/tv/TVMetadataBadges.tsx | 13 ++- components/tv/TVNextEpisodeCountdown.tsx | 7 +- components/tv/TVOptionButton.tsx | 91 ++++++++++++++----- components/tv/TVOptionCard.tsx | 5 +- components/tv/TVOptionSelector.tsx | 3 +- components/tv/TVSeriesNavigation.tsx | 5 +- components/tv/TVSeriesSeasonCard.tsx | 21 +++-- components/tv/TVSubtitleResultCard.tsx | 9 +- components/tv/TVTabButton.tsx | 3 +- components/tv/TVTechnicalDetails.tsx | 13 +-- components/tv/TVTrackCard.tsx | 5 +- components/tv/settings/TVLogoutButton.tsx | 3 +- components/tv/settings/TVSectionHeader.tsx | 3 +- .../tv/settings/TVSettingsOptionButton.tsx | 7 +- components/tv/settings/TVSettingsRow.tsx | 7 +- components/tv/settings/TVSettingsStepper.tsx | 7 +- .../tv/settings/TVSettingsTextInput.tsx | 11 ++- components/tv/settings/TVSettingsToggle.tsx | 5 +- constants/TVTypography.ts | 25 +++++ 35 files changed, 437 insertions(+), 167 deletions(-) create mode 100644 constants/TVTypography.ts diff --git a/components/Badge.tsx b/components/Badge.tsx index c5806c8d5..0cb76a74d 100644 --- a/components/Badge.tsx +++ b/components/Badge.tsx @@ -1,5 +1,7 @@ +import { BlurView } from "expo-blur"; import { Platform, StyleSheet, View, type ViewProps } from "react-native"; import { GlassEffectView } from "react-native-glass-effect-view"; +import { TVTypography } from "@/constants/TVTypography"; import { Text } from "./common/Text"; interface Props extends ViewProps { @@ -38,8 +40,45 @@ export const Badge: React.FC = ({ ); } - // On TV, use transparent backgrounds for a cleaner look - const isTV = Platform.isTV; + // On TV, use BlurView for consistent styling + if (Platform.isTV) { + return ( + + + {iconLeft && {iconLeft}} + + {text} + + + + ); + } return ( = ({ alignSelf: "flex-start", flexDirection: "row", alignItems: "center", - backgroundColor: isTV - ? "rgba(255,255,255,0.1)" - : variant === "purple" - ? "#9333ea" - : "#262626", + backgroundColor: variant === "purple" ? "#9333ea" : "#262626", }, props.style, ]} diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index 540f153c4..bc83eafaa 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -1,4 +1,5 @@ // GenreTags.tsx +import { BlurView } from "expo-blur"; import type React from "react"; import { Platform, @@ -9,6 +10,7 @@ import { type ViewProps, } from "react-native"; import { GlassEffectView } from "react-native-glass-effect-view"; +import { TVTypography } from "@/constants/TVTypography"; import { Text } from "./common/Text"; interface TagProps { @@ -40,6 +42,32 @@ export const Tag: React.FC< ); } + // TV-specific styling with blur background + if (Platform.isTV) { + return ( + + + + {text} + + + + ); + } + return ( @@ -66,7 +94,8 @@ export const Tags: React.FC< return ( {tags.map((tag, idx) => ( diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 9a99f001e..c19644b18 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -6,6 +6,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useQueryClient } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -28,6 +29,7 @@ import { TVSeriesNavigation, TVTechnicalDetails, } from "@/components/tv"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; @@ -453,7 +455,7 @@ export const ItemContentTV: React.FC = React.memo( = React.memo( ) : ( @@ -476,10 +478,10 @@ export const ItemContentTV: React.FC = React.memo( {/* Episode info for TV shows */} {item.Type === "Episode" && ( - + = React.memo( S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name} @@ -515,18 +517,34 @@ export const ItemContentTV: React.FC = React.memo( {/* Overview */} {item.Overview && ( - - {item.Overview} - + + + {item.Overview} + + + )} {/* Action buttons */} @@ -550,7 +568,7 @@ export const ItemContentTV: React.FC = React.memo( /> = React.memo( (item.UserData?.PlaybackPositionTicks || 0) / item.RunTimeTicks } + fillColor='#FFFFFF' /> )} diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx index 6da3befa6..a4aab9b5c 100644 --- a/components/home/Favorites.tv.tsx +++ b/components/home/Favorites.tv.tsx @@ -11,6 +11,7 @@ import heart from "@/assets/icons/heart.fill.png"; import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; import { Colors } from "@/constants/Colors"; +import { TVTypography } from "@/constants/TVTypography"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; const HORIZONTAL_PADDING = 60; @@ -147,7 +148,7 @@ export const Favorites = () => { /> { style={{ textAlign: "center", opacity: 0.7, - fontSize: 18, + fontSize: TVTypography.body, color: "#FFFFFF", }} > diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 28b0a79f0..2acdfdf94 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -31,6 +31,7 @@ import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrol import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; import { Loader } from "@/components/Loader"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; @@ -525,7 +526,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: 18, + fontSize: TVTypography.body, color: "#FFFFFF", }} > @@ -577,7 +578,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: 18, + fontSize: TVTypography.body, color: "#FFFFFF", }} > diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index c66004a08..8de15f879 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -21,6 +21,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { Colors } from "@/constants/Colors"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import ContinueWatchingPoster, { @@ -54,12 +55,19 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { {item.Type === "Episode" ? ( <> - + {item.Name} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {" - "} @@ -68,10 +76,19 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { ) : ( <> - + {item.Name} - + {item.ProductionYear} @@ -119,7 +136,13 @@ const TVSeeAllCard: React.FC<{ color='white' style={{ marginBottom: 8 }} /> - + {t("common.seeAll", { defaultValue: "See all" })} @@ -369,7 +392,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ {/* Section Header */} = ({ {isLoading === false && allItems.length === 0 && ( {t("home.no_items")} @@ -420,7 +447,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: 16, + fontSize: TVTypography.callout, }} numberOfLines={1} > diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 264a60395..7bd2320c2 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -16,6 +16,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -28,10 +29,19 @@ const SCALE_PADDING = 20; const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { return ( - + {item.Name} - + {item.ProductionYear} @@ -145,7 +155,7 @@ const WatchlistSection: React.FC = ({ = ({ item }) => { return ( - + {item.Name} - + {item.ProductionYear} @@ -208,7 +218,7 @@ export const StreamystatsRecommendations: React.FC = ({ = ({ style={{ width: TV_EPISODE_WIDTH, aspectRatio: 16 / 9, - borderRadius: 12, + borderRadius: 24, overflow: "hidden", backgroundColor: "#1a1a1a", - borderWidth: 1, - borderColor: "#262626", }} > {thumbnailUrl ? ( @@ -109,7 +108,7 @@ export const TVEpisodeCard: React.FC = ({ {episodeLabel && ( = ({ )} {duration && ( <> - - {duration} + + • + + + {duration} + )} = ({ item }) => { ) : ( = ({ item }) => { }} > {yearString && ( - {yearString} + + {yearString} + )} {item.OfficialRating && ( @@ -101,17 +105,34 @@ export const TVSeriesHeader: React.FC = ({ item }) => { {/* Overview */} {item.Overview && ( - - {item.Overview} - + + + {item.Overview} + + + )} ); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index f393fe943..23a78b9ab 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -32,6 +32,7 @@ import { TVEpisodeCard, } from "@/components/series/TVEpisodeCard"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useDownload } from "@/providers/DownloadProvider"; @@ -146,7 +147,7 @@ const TVSeasonButton: React.FC<{ const animateTo = (v: number) => Animated.timing(scale, { toValue: v, - duration: 120, + duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); @@ -156,7 +157,7 @@ const TVSeasonButton: React.FC<{ onPress={onPress} onFocus={() => { setFocused(true); - animateTo(1.02); + animateTo(1.05); }} onBlur={() => { setFocused(false); @@ -170,33 +171,34 @@ const TVSeasonButton: React.FC<{ transform: [{ scale }], shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.4 : 0, - shadowRadius: focused ? 12 : 0, + shadowOpacity: focused ? 0.6 : 0, + shadowRadius: focused ? 20 : 0, }} > {seasonName} @@ -572,7 +574,7 @@ export const TVSeriesPage: React.FC = ({ /> = ({ = ({ diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index b477cb5de..888da829e 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVActorCardProps { @@ -22,7 +23,7 @@ export const TVActorCard = React.forwardRef( useTVFocusAnimation({ scaleAmount: 1.08 }); const imageUrl = person.Id - ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90` + ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90` : null; return ( @@ -38,7 +39,7 @@ export const TVActorCard = React.forwardRef( animatedStyle, { alignItems: "center", - width: 120, + width: 160, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.5 : 0, @@ -48,12 +49,12 @@ export const TVActorCard = React.forwardRef( > ( > @@ -83,11 +84,11 @@ export const TVActorCard = React.forwardRef( @@ -97,7 +98,7 @@ export const TVActorCard = React.forwardRef( {person.Role && ( = ({ /> = React.memo( = React.memo( = React.memo( > {t("item_card.director")} - + {director.Name} @@ -54,7 +55,7 @@ export const TVCastCrewText: React.FC = React.memo( = React.memo( > {t("item_card.cast")} - + {cast.map((c) => c.Name).join(", ")} diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx index 9c58e2734..828ca3f6b 100644 --- a/components/tv/TVCastSection.tsx +++ b/components/tv/TVCastSection.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, TVFocusGuideView, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { TVActorCard } from "./TVActorCard"; export interface TVCastSectionProps { @@ -30,13 +31,13 @@ export const TVCastSection: React.FC = React.memo( } return ( - + {t("item_card.cast")} @@ -54,8 +55,8 @@ export const TVCastSection: React.FC = React.memo( style={{ marginHorizontal: -80, overflow: "visible" }} contentContainerStyle={{ paddingHorizontal: 80, - paddingVertical: 12, - gap: 20, + paddingVertical: 16, + gap: 28, }} > {cast.map((person, index) => ( diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx index b2b8e6b12..24e3ec845 100644 --- a/components/tv/TVLanguageCard.tsx +++ b/components/tv/TVLanguageCard.tsx @@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVLanguageCardProps { @@ -81,11 +82,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, }, languageCardText: { - fontSize: 15, + fontSize: TVTypography.callout, fontWeight: "500", }, languageCardCode: { - fontSize: 11, + fontSize: TVTypography.callout, marginTop: 2, }, checkmark: { diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx index feea26092..b2ccab2e0 100644 --- a/components/tv/TVMetadataBadges.tsx +++ b/components/tv/TVMetadataBadges.tsx @@ -3,6 +3,7 @@ import React from "react"; import { View } from "react-native"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; export interface TVMetadataBadgesProps { year?: number | null; @@ -19,15 +20,19 @@ export const TVMetadataBadges: React.FC = React.memo( flexDirection: "row", alignItems: "center", flexWrap: "wrap", - gap: 12, - marginBottom: 20, + gap: 16, + marginBottom: 24, }} > {year != null && ( - {year} + + {year} + )} {duration && ( - {duration} + + {duration} + )} {officialRating && } {communityRating != null && ( diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 222e34135..2030d109d 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -13,6 +13,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; export interface TVNextEpisodeCountdownProps { @@ -129,19 +130,19 @@ const styles = StyleSheet.create({ width: 280, }, label: { - fontSize: 13, + fontSize: TVTypography.callout, color: "rgba(255,255,255,0.5)", textTransform: "uppercase", letterSpacing: 1, marginBottom: 4, }, seriesName: { - fontSize: 16, + fontSize: TVTypography.callout, color: "rgba(255,255,255,0.7)", marginBottom: 2, }, episodeInfo: { - fontSize: 20, + fontSize: TVTypography.body, color: "#fff", fontWeight: "600", marginBottom: 12, diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx index 8ef8a7dd7..342caac95 100644 --- a/components/tv/TVOptionButton.tsx +++ b/components/tv/TVOptionButton.tsx @@ -1,6 +1,8 @@ +import { BlurView } from "expo-blur"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVOptionButtonProps { @@ -34,36 +36,77 @@ export const TVOptionButton = React.forwardRef( }, ]} > - - - {label} - - + {label} + + + {value} + + + ) : ( + - {value} - - + + + {label} + + + {value} + + + + )} ); diff --git a/components/tv/TVOptionCard.tsx b/components/tv/TVOptionCard.tsx index 912f46dfe..ee36fc646 100644 --- a/components/tv/TVOptionCard.tsx +++ b/components/tv/TVOptionCard.tsx @@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVOptionCardProps { @@ -58,7 +59,7 @@ export const TVOptionCard = React.forwardRef( > ( {sublabel && ( = React.memo( {t("item_card.from_this_series") || "From this Series"} diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx index b7d97642f..eb69f7f8a 100644 --- a/components/tv/TVSeriesSeasonCard.tsx +++ b/components/tv/TVSeriesSeasonCard.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVSeriesSeasonCardProps { @@ -34,22 +35,22 @@ export const TVSeriesSeasonCard: React.FC = ({ style={[ animatedStyle, { - width: 140, + width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.5 : 0, - shadowRadius: focused ? 16 : 0, + shadowRadius: focused ? 20 : 0, }, ]} > = ({ alignItems: "center", }} > - + )} @@ -89,7 +90,7 @@ export const TVSeriesSeasonCard: React.FC = ({ {subtitle && ( = ({ > = React.memo( {t("item_card.technical_details")} @@ -36,7 +37,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( > Video - + {videoStream.DisplayTitle || `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} @@ -55,7 +56,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( > Audio - + {audioStream.DisplayTitle || `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx index 4945285fe..e1b7106f4 100644 --- a/components/tv/TVTrackCard.tsx +++ b/components/tv/TVTrackCard.tsx @@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVTrackCardProps { @@ -86,11 +87,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, }, trackCardText: { - fontSize: 16, + fontSize: TVTypography.callout, textAlign: "center", }, trackCardSublabel: { - fontSize: 12, + fontSize: TVTypography.callout, marginTop: 2, }, checkmark: { diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx index d9d214bc5..9df832f10 100644 --- a/components/tv/settings/TVLogoutButton.tsx +++ b/components/tv/settings/TVLogoutButton.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVLogoutButtonProps { @@ -48,7 +49,7 @@ export const TVLogoutButton: React.FC = ({ > = ({ title }) => ( = ({ }, ]} > - {label} + + {label} + = ({ }, ]} > - {label} + + {label} + = ({ focusable={!disabled} > - {label} + + {label} + @@ -86,7 +89,7 @@ export const TVSettingsStepper: React.FC = ({ = ({ }, ]} > - + {label} = ({ autoCapitalize='none' autoCorrect={false} style={{ - fontSize: 18, + fontSize: TVTypography.body, color: "#FFFFFF", backgroundColor: "rgba(255, 255, 255, 0.05)", borderRadius: 8, diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx index cfeb182fb..c50a6518b 100644 --- a/components/tv/settings/TVSettingsToggle.tsx +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVSettingsToggleProps { @@ -47,7 +48,9 @@ export const TVSettingsToggle: React.FC = ({ }, ]} > - {label} + + {label} + Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 071/309] feat(tv): add favorite button to item detail page --- components/ItemContent.tv.tsx | 2 ++ components/tv/TVFavoriteButton.tsx | 23 +++++++++++++++++++++++ components/tv/index.ts | 2 ++ 3 files changed, 27 insertions(+) create mode 100644 components/tv/TVFavoriteButton.tsx diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index c19644b18..ac4fad327 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -22,6 +22,7 @@ import { TVButton, TVCastCrewText, TVCastSection, + TVFavoriteButton, TVMetadataBadges, TVOptionButton, TVProgressBar, @@ -578,6 +579,7 @@ export const ItemContentTV: React.FC = React.memo( : t("common.play")} + diff --git a/components/tv/TVFavoriteButton.tsx b/components/tv/TVFavoriteButton.tsx new file mode 100644 index 000000000..6be3f9772 --- /dev/null +++ b/components/tv/TVFavoriteButton.tsx @@ -0,0 +1,23 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { useFavorite } from "@/hooks/useFavorite"; +import { TVButton } from "./TVButton"; + +export interface TVFavoriteButtonProps { + item: BaseItemDto; +} + +export const TVFavoriteButton: React.FC = ({ item }) => { + const { isFavorite, toggleFavorite } = useFavorite(item); + + return ( + + + + ); +}; diff --git a/components/tv/index.ts b/components/tv/index.ts index d05dc8df7..3620945d7 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -23,6 +23,8 @@ export { TVCastSection } from "./TVCastSection"; // Player control components export type { TVControlButtonProps } from "./TVControlButton"; export { TVControlButton } from "./TVControlButton"; +export type { TVFavoriteButtonProps } from "./TVFavoriteButton"; +export { TVFavoriteButton } from "./TVFavoriteButton"; export type { TVFocusablePosterProps } from "./TVFocusablePoster"; export { TVFocusablePoster } from "./TVFocusablePoster"; export type { TVLanguageCardProps } from "./TVLanguageCard"; From 5f44540b6f28fb96ee821ff74a7c2bc29e2c8056 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 20:01:00 +0100 Subject: [PATCH 072/309] fix(tv): design --- components/common/ProgressBar.tsx | 5 +-- .../InfiniteScrollingCollectionList.tv.tsx | 33 ++----------------- components/series/TVSeriesHeader.tsx | 2 +- components/tv/TVProgressBar.tsx | 2 +- 4 files changed, 8 insertions(+), 34 deletions(-) diff --git a/components/common/ProgressBar.tsx b/components/common/ProgressBar.tsx index 1a47327e6..23ff1249a 100644 --- a/components/common/ProgressBar.tsx +++ b/components/common/ProgressBar.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, { useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; interface ProgressBarProps { item: BaseItemDto; @@ -39,8 +39,9 @@ export const ProgressBar: React.FC = ({ item }) => { ); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 8de15f879..4b85c441c 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -20,7 +20,6 @@ import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { Colors } from "@/constants/Colors"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; @@ -172,35 +171,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; - // Track focus within section and scroll back to start when leaving + // Track focus within section for item focus/blur callbacks const flatListRef = useRef>(null); - const [focusedCount, setFocusedCount] = useState(0); - const prevFocusedCount = useRef(0); - const scrollBackTimerRef = useRef | null>(null); - - // When section loses all focus, scroll back to start (with debounce to avoid - // triggering during transient focus changes like infinite scroll loading) - useEffect(() => { - // Clear any pending scroll-back timer - if (scrollBackTimerRef.current) { - clearTimeout(scrollBackTimerRef.current); - scrollBackTimerRef.current = null; - } - - if (prevFocusedCount.current > 0 && focusedCount === 0) { - // Debounce the scroll-back to avoid triggering during re-renders - scrollBackTimerRef.current = setTimeout(() => { - flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); - }, 150); - } - prevFocusedCount.current = focusedCount; - - return () => { - if (scrollBackTimerRef.current) { - clearTimeout(scrollBackTimerRef.current); - } - }; - }, [focusedCount]); + const [_focusedCount, setFocusedCount] = useState(0); const handleItemFocus = useCallback( (item: BaseItemDto) => { @@ -494,7 +467,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ height: orientation === "horizontal" ? 191 : 315, }} > - + )} {parentId && allItems.length > 0 && ( diff --git a/components/series/TVSeriesHeader.tsx b/components/series/TVSeriesHeader.tsx index 6be2e56d3..02d161488 100644 --- a/components/series/TVSeriesHeader.tsx +++ b/components/series/TVSeriesHeader.tsx @@ -80,7 +80,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => { }} > {yearString && ( - + {yearString} )} diff --git a/components/tv/TVProgressBar.tsx b/components/tv/TVProgressBar.tsx index 54ac5df14..026b92474 100644 --- a/components/tv/TVProgressBar.tsx +++ b/components/tv/TVProgressBar.tsx @@ -18,7 +18,7 @@ export const TVProgressBar: React.FC = React.memo( ({ progress, trackColor = "rgba(255,255,255,0.2)", - fillColor = "#a855f7", + fillColor = "#ffffff", maxWidth = 400, height = 4, }) => { From e3b4952c6093263eb6852b898b1fbed7c54b52b1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 073/309] fix(tv): resolve jellyseerr detail page focus navigation loop --- components/jellyseerr/tv/TVJellyseerrPage.tsx | 979 ++++++++++++++++++ components/jellyseerr/tv/TVRequestModal.tsx | 518 +++++++++ .../jellyseerr/tv/TVRequestOptionRow.tsx | 85 ++ .../jellyseerr/tv/TVToggleOptionRow.tsx | 115 ++ components/jellyseerr/tv/index.ts | 4 + components/tv/TVButton.tsx | 13 +- 6 files changed, 1710 insertions(+), 4 deletions(-) create mode 100644 components/jellyseerr/tv/TVJellyseerrPage.tsx create mode 100644 components/jellyseerr/tv/TVRequestModal.tsx create mode 100644 components/jellyseerr/tv/TVRequestOptionRow.tsx create mode 100644 components/jellyseerr/tv/TVToggleOptionRow.tsx create mode 100644 components/jellyseerr/tv/index.ts diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx new file mode 100644 index 000000000..f36609729 --- /dev/null +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -0,0 +1,979 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useLocalSearchParams } from "expo-router"; +import { orderBy } from "lodash"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + FlatList, + Pressable, + ScrollView, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon"; +import { Loader } from "@/components/Loader"; +import { JellyserrRatings } from "@/components/Ratings"; +import { TVButton } from "@/components/tv"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVRequestModal } from "@/hooks/useTVRequestModal"; +import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; +import type Season from "@/utils/jellyseerr/server/entity/Season"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { + hasPermission, + Permission, +} from "@/utils/jellyseerr/server/lib/permissions"; +import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; +import type { + MovieResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +// Cast card component +interface TVCastCardProps { + person: { + id: number; + name: string; + character?: string; + profilePath?: string; + }; + imageProxy: (path: string, size?: string) => string; + onPress: () => void; + isFirst?: boolean; + refSetter?: (ref: View | null) => void; +} + +const TVCastCard: React.FC = ({ + person, + imageProxy, + onPress, + isFirst, + refSetter, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const profileUrl = person.profilePath + ? imageProxy(person.profilePath, "w185") + : null; + + return ( + + + + {profileUrl ? ( + + ) : ( + + + + )} + + + {person.name} + + {person.character && ( + + {person.character} + + )} + + + ); +}; + +// Season card component +interface TVSeasonCardProps { + season: { + id: number; + seasonNumber: number; + episodeCount: number; + status: MediaStatus; + }; + onPress: () => void; + canRequest: boolean; + disabled?: boolean; + onCardFocus?: () => void; +} + +const TVSeasonCard: React.FC = ({ + season, + onPress, + canRequest, + disabled = false, + onCardFocus, +}) => { + const { t } = useTranslation(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + const handleCardFocus = useCallback(() => { + handleFocus(); + onCardFocus?.(); + }, [handleFocus, onCardFocus]); + + return ( + + + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + + + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, + })} + + + + ); +}; + +export const TVJellyseerrPage: React.FC = () => { + const insets = useSafeAreaInsets(); + const params = useLocalSearchParams(); + const { t } = useTranslation(); + const router = useRouter(); + + const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } = + params as unknown as { + mediaTitle: string; + releaseYear: number; + canRequest: string; + posterSrc: string; + mediaType: MediaType; + } & Partial; + + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + const { showRequestModal } = useTVRequestModal(); + const [lastActionButtonRef, setLastActionButtonRef] = useState( + null, + ); + + // Scroll control ref + const mainScrollRef = useRef(null); + const scrollPositionRef = useRef(0); + + const { + data: details, + isFetching, + isLoading, + refetch, + } = useQuery({ + enabled: !!jellyseerrApi && !!result && !!result.id, + queryKey: ["jellyseerr", "detail", mediaType, result.id], + staleTime: 0, + refetchOnMount: true, + queryFn: async () => { + return mediaType === MediaType.MOVIE + ? jellyseerrApi?.movieDetails(result.id!) + : jellyseerrApi?.tvDetails(result.id!); + }, + }); + + const [canRequest, hasAdvancedRequestPermission] = + useJellyseerrCanRequest(details); + + const canManageRequests = useMemo(() => { + if (!jellyseerrUser) return false; + return hasPermission( + Permission.MANAGE_REQUESTS, + jellyseerrUser.permissions, + ); + }, [jellyseerrUser]); + + const pendingRequest = useMemo(() => { + return details?.mediaInfo?.requests?.find( + (r: MediaRequest) => r.status === MediaRequestStatus.PENDING, + ); + }, [details]); + + // Get seasons with status for TV shows + const seasons = useMemo(() => { + if (!details || mediaType !== MediaType.TV) return []; + const tvDetails = details as TvDetails; + const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter( + (s: Season) => s.seasonNumber !== 0, + ); + const requestedSeasons = + tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ?? + []; + return ( + tvDetails.seasons?.map((season) => ({ + ...season, + status: + mediaInfoSeasons?.find( + (mediaSeason: Season) => + mediaSeason.seasonNumber === season.seasonNumber, + )?.status ?? + requestedSeasons?.find( + (s: Season) => s.seasonNumber === season.seasonNumber, + )?.status ?? + MediaStatus.UNKNOWN, + })) ?? [] + ); + }, [details, mediaType]); + + const allSeasonsAvailable = useMemo( + () => seasons.every((season) => season.status === MediaStatus.AVAILABLE), + [seasons], + ); + + // Get cast + const cast = useMemo(() => { + return details?.credits?.cast?.slice(0, 10) ?? []; + }, [details]); + + // Backdrop URL + const backdropUrl = useMemo(() => { + const path = details?.backdropPath || result.backdropPath; + return path + ? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces") + : null; + }, [details, result.backdropPath, jellyseerrApi]); + + // Poster URL + const posterUrl = useMemo(() => { + if (posterSrc) return posterSrc; + const path = details?.posterPath; + return path ? jellyseerrApi?.imageProxy(path, "w342") : null; + }, [posterSrc, details, jellyseerrApi]); + + // Handlers + const handleApproveRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + try { + await jellyseerrApi?.approveRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_approved")); + refetch(); + } catch (_error) { + toast.error(t("jellyseerr.toasts.failed_to_approve_request")); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleDeclineRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + try { + await jellyseerrApi?.declineRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_declined")); + refetch(); + } catch (_error) { + toast.error(t("jellyseerr.toasts.failed_to_decline_request")); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleRequest = useCallback(async () => { + const body: MediaRequestBody = { + mediaId: Number(result.id!), + mediaType: mediaType!, + tvdbId: details?.externalIds?.tvdbId, + ...(mediaType === MediaType.TV && { + seasons: (details as TvDetails)?.seasons + ?.filter?.((s) => s.seasonNumber !== 0) + ?.map?.((s) => s.seasonNumber), + }), + }; + + if (hasAdvancedRequestPermission) { + showRequestModal({ + requestBody: body, + title: mediaTitle, + id: result.id!, + mediaType: mediaType!, + onRequested: refetch, + }); + return; + } + + requestMedia(mediaTitle, body, refetch); + }, [ + details, + result, + requestMedia, + hasAdvancedRequestPermission, + mediaTitle, + refetch, + mediaType, + showRequestModal, + ]); + + const handleSeasonRequest = useCallback( + (seasonNumber: number) => { + const body: MediaRequestBody = { + mediaId: Number(result.id!), + mediaType: MediaType.TV, + tvdbId: details?.externalIds?.tvdbId, + seasons: [seasonNumber], + }; + + if (hasAdvancedRequestPermission) { + showRequestModal({ + requestBody: body, + title: mediaTitle, + id: result.id!, + mediaType: MediaType.TV, + onRequested: refetch, + }); + return; + } + + const seasonTitle = t("jellyseerr.season_number", { + season_number: seasonNumber, + }); + requestMedia(`${mediaTitle}, ${seasonTitle}`, body, refetch); + }, + [ + details, + result, + hasAdvancedRequestPermission, + requestMedia, + mediaTitle, + refetch, + t, + showRequestModal, + ], + ); + + const handleRequestAll = useCallback(() => { + const body: MediaRequestBody = { + mediaId: Number(result.id!), + mediaType: MediaType.TV, + tvdbId: details?.externalIds?.tvdbId, + seasons: seasons + .filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) + .map((s) => s.seasonNumber), + }; + + if (hasAdvancedRequestPermission) { + showRequestModal({ + requestBody: body, + title: mediaTitle, + id: result.id!, + mediaType: MediaType.TV, + onRequested: refetch, + }); + return; + } + + requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch); + }, [ + details, + result, + seasons, + hasAdvancedRequestPermission, + requestMedia, + mediaTitle, + refetch, + t, + showRequestModal, + ]); + + // Restore scroll position when navigating within seasons section + const handleSeasonsFocus = useCallback(() => { + // Use requestAnimationFrame to restore scroll after TV focus engine scrolls + requestAnimationFrame(() => { + mainScrollRef.current?.scrollTo({ + y: scrollPositionRef.current, + animated: false, + }); + }); + }, []); + + const handlePlay = useCallback(() => { + const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId; + if (!jellyfinMediaId) return; + router.push({ + pathname: + mediaType === MediaType.MOVIE + ? "/(auth)/(tabs)/(search)/items/page" + : "/(auth)/(tabs)/(search)/series/[id]", + params: { id: jellyfinMediaId }, + }); + }, [details, mediaType, router]); + + const handleCastPress = useCallback( + (personId: number) => { + router.push(`/(auth)/jellyseerr/person/${personId}` as any); + }, + [router], + ); + + const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId; + const requestedByName = + pendingRequest?.requestedBy?.displayName || + pendingRequest?.requestedBy?.username || + pendingRequest?.requestedBy?.jellyfinUsername || + t("jellyseerr.unknown_user"); + + if (isLoading || isFetching) { + return ( + + + + ); + } + + return ( + + {/* Full-screen backdrop */} + + {backdropUrl ? ( + + ) : ( + + )} + {/* Bottom gradient */} + + {/* Left gradient */} + + + + {/* Main content */} + { + scrollPositionRef.current = e.nativeEvent.contentOffset.y; + }} + > + {/* Top section - Poster + Content */} + + {/* Left side - Poster */} + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + {/* Right side - Content */} + + {/* Ratings */} + {details && ( + + )} + + {/* Title */} + + {mediaTitle} + + + {/* Year */} + + {releaseYear} + + + {/* Genres */} + {details?.genres && details.genres.length > 0 && ( + + g.name)} /> + + )} + + {/* Overview */} + {(details?.overview || result.overview) && ( + + + + {details?.overview || result.overview} + + + + )} + + {/* Action buttons */} + + {hasJellyfinMedia && ( + + + + {t("common.play")} + + + )} + + {canRequest && ( + + + + {t("jellyseerr.request_button")} + + + )} + + + {/* Approve/Decline for managers */} + {canManageRequests && pendingRequest && ( + + + + + {t("jellyseerr.requested_by", { user: requestedByName })} + + + + + + + + {t("jellyseerr.approve")} + + + + + + + {t("jellyseerr.decline")} + + + + + )} + + + + {/* Seasons section (TV shows only) */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && ( + + + {t("item_card.seasons")} + + + + {!allSeasonsAvailable && ( + + + + {t("jellyseerr.request_all")} + + + )} + {orderBy( + seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "desc", + ).map((season) => { + const canRequestSeason = + season.status === MediaStatus.UNKNOWN; + return ( + handleSeasonRequest(season.seasonNumber)} + canRequest={canRequestSeason} + onCardFocus={handleSeasonsFocus} + /> + ); + })} + + + )} + + {/* Cast section */} + {cast.length > 0 && jellyseerrApi && ( + + + {t("jellyseerr.cast")} + + + {/* Focus guide for upward navigation from cast to action buttons */} + {lastActionButtonRef && ( + + )} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingVertical: 16, + gap: 28, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + + jellyseerrApi.imageProxy(path, size || "w185") + } + onPress={() => handleCastPress(item.id)} + isFirst={index === 0} + /> + )} + /> + + )} + + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestModal.tsx b/components/jellyseerr/tv/TVRequestModal.tsx new file mode 100644 index 000000000..b9bfe9b8f --- /dev/null +++ b/components/jellyseerr/tv/TVRequestModal.tsx @@ -0,0 +1,518 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + BackHandler, + Easing, + ScrollView, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVButton, TVOptionSelector } from "@/components/tv"; +import type { TVOptionItem } from "@/components/tv/TVOptionSelector"; +import { TVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import type { + QualityProfile, + RootFolder, + Tag, +} from "@/utils/jellyseerr/server/api/servarr/base"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { TVRequestOptionRow } from "./TVRequestOptionRow"; +import { TVToggleOptionRow } from "./TVToggleOptionRow"; + +interface TVRequestModalProps { + visible: boolean; + requestBody?: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onClose: () => void; + onRequested: () => void; +} + +export const TVRequestModal: React.FC = ({ + visible, + requestBody, + title, + id, + mediaType, + onClose, + onRequested, +}) => { + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + + const [requestOverrides, setRequestOverrides] = useState({ + mediaId: Number(id), + mediaType, + userId: jellyseerrUser?.id, + }); + + const [activeSelector, setActiveSelector] = useState< + "profile" | "folder" | "user" | null + >(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + // Handle back button to close modal + useEffect(() => { + if (!visible) return; + + const handleBackPress = () => { + // If a sub-selector is open, close it first + if (activeSelector) { + setActiveSelector(null); + } else { + onClose(); + } + return true; // Prevent default back behavior + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [visible, activeSelector, onClose]); + + const { data: serviceSettings } = useQuery({ + queryKey: ["jellyseerr", "request", mediaType, "service"], + queryFn: async () => + jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"), + enabled: !!jellyseerrApi && !!jellyseerrUser && visible, + }); + + const { data: users } = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => + jellyseerrApi?.user({ take: 1000, sort: "displayname" }), + enabled: !!jellyseerrApi && !!jellyseerrUser && visible, + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.((v) => v.isDefault), + [serviceSettings], + ); + + const { data: defaultServiceDetails } = useQuery({ + queryKey: [ + "jellyseerr", + "request", + mediaType, + "service", + "details", + defaultService?.id, + ], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id, + })); + return jellyseerrApi?.serviceDetails( + mediaType === "movie" ? "radarr" : "sonarr", + defaultService!.id, + ); + }, + enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible, + }); + + const defaultProfile: QualityProfile | undefined = useMemo( + () => + defaultServiceDetails?.profiles.find( + (p) => p.id === defaultServiceDetails.server?.activeProfileId, + ), + [defaultServiceDetails], + ); + + const defaultFolder: RootFolder | undefined = useMemo( + () => + defaultServiceDetails?.rootFolders.find( + (f) => f.path === defaultServiceDetails.server?.activeDirectory, + ), + [defaultServiceDetails], + ); + + const defaultTags: Tag[] = useMemo(() => { + return ( + defaultServiceDetails?.tags.filter((t) => + defaultServiceDetails?.server.activeTags?.includes(t.id), + ) ?? [] + ); + }, [defaultServiceDetails]); + + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + // Option builders + const qualityProfileOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.profiles.map((profile) => ({ + label: profile.name, + value: profile.id, + selected: + (requestOverrides.profileId || defaultProfile?.id) === profile.id, + })) || [], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + ], + ); + + const rootFolderOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.rootFolders.map((folder) => ({ + label: pathTitleExtractor(folder), + value: folder.path, + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === folder.path, + })) || [], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + ], + ); + + const userOptions: TVOptionItem[] = useMemo( + () => + users?.map((user) => ({ + label: user.displayName, + value: user.id, + selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id, + })) || [], + [users, jellyseerrUser, requestOverrides.userId], + ); + + const tagItems = useMemo(() => { + return ( + defaultServiceDetails?.tags.map((tag) => ({ + id: tag.id, + label: tag.label, + selected: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + })) ?? [] + ); + }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]); + + // Selected display values + const selectedProfileName = useMemo(() => { + const profile = defaultServiceDetails?.profiles.find( + (p) => p.id === (requestOverrides.profileId || defaultProfile?.id), + ); + return profile?.name || defaultProfile?.name || t("jellyseerr.select"); + }, [ + defaultServiceDetails?.profiles, + requestOverrides.profileId, + defaultProfile, + t, + ]); + + const selectedFolderName = useMemo(() => { + const folder = defaultServiceDetails?.rootFolders.find( + (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path), + ); + return folder + ? pathTitleExtractor(folder) + : defaultFolder + ? pathTitleExtractor(defaultFolder) + : t("jellyseerr.select"); + }, [ + defaultServiceDetails?.rootFolders, + requestOverrides.rootFolder, + defaultFolder, + t, + ]); + + const selectedUserName = useMemo(() => { + const user = users?.find( + (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id), + ); + return ( + user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select") + ); + }, [users, requestOverrides.userId, jellyseerrUser, t]); + + // Handlers + const handleProfileChange = useCallback((profileId: number) => { + setRequestOverrides((prev) => ({ ...prev, profileId })); + setActiveSelector(null); + }, []); + + const handleFolderChange = useCallback((rootFolder: string) => { + setRequestOverrides((prev) => ({ ...prev, rootFolder })); + setActiveSelector(null); + }, []); + + const handleUserChange = useCallback((userId: number) => { + setRequestOverrides((prev) => ({ ...prev, userId })); + setActiveSelector(null); + }, []); + + const handleTagToggle = useCallback( + (tagId: number) => { + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tagId); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tagId) + : [...currentTags, tagId], + }; + }); + }, + [defaultTags], + ); + + const handleRequest = useCallback(() => { + const body = { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile?.id, + rootFolder: defaultFolder?.path, + tags: defaultTags.map((t) => t.id), + ...requestBody, + ...requestOverrides, + }; + + const seasonTitle = + requestBody?.seasons?.length === 1 + ? t("jellyseerr.season_number", { + season_number: requestBody.seasons[0], + }) + : requestBody?.seasons && requestBody.seasons.length > 1 + ? t("jellyseerr.season_all") + : undefined; + + requestMedia( + seasonTitle ? `${title}, ${seasonTitle}` : title, + body, + onRequested, + ); + }, [ + requestBody, + requestOverrides, + defaultProfile, + defaultFolder, + defaultTags, + defaultService, + defaultServiceDetails, + title, + requestMedia, + onRequested, + t, + ]); + + if (!visible) return null; + + const isDataLoaded = defaultService && defaultServiceDetails && users; + + return ( + <> + + + + + + {t("jellyseerr.advanced")} + + + {title} + + + {isDataLoaded ? ( + + + setActiveSelector("profile")} + hasTVPreferredFocus + /> + setActiveSelector("folder")} + /> + setActiveSelector("user")} + /> + + {tagItems.length > 0 && ( + + )} + + + ) : ( + + + {t("common.loading")} + + + )} + + + + + + {t("jellyseerr.request_button")} + + + + + + + + + {/* Sub-selectors */} + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + cardWidth={280} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestOptionRow.tsx b/components/jellyseerr/tv/TVRequestOptionRow.tsx new file mode 100644 index 000000000..60748e407 --- /dev/null +++ b/components/jellyseerr/tv/TVRequestOptionRow.tsx @@ -0,0 +1,85 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; + +interface TVRequestOptionRowProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVRequestOptionRow: React.FC = ({ + label, + value, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + }); + + return ( + + + + {label} + + + + {value} + + + + + + ); +}; diff --git a/components/jellyseerr/tv/TVToggleOptionRow.tsx b/components/jellyseerr/tv/TVToggleOptionRow.tsx new file mode 100644 index 000000000..46be5f60c --- /dev/null +++ b/components/jellyseerr/tv/TVToggleOptionRow.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Animated, Pressable, ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; + +interface ToggleItem { + id: number; + label: string; + selected: boolean; +} + +interface TVToggleChipProps { + item: ToggleItem; + onToggle: (id: number) => void; + disabled?: boolean; +} + +const TVToggleChip: React.FC = ({ + item, + onToggle, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.08, + }); + + return ( + onToggle(item.id)} + onFocus={handleFocus} + onBlur={handleBlur} + disabled={disabled} + focusable={!disabled} + > + + + {item.label} + + + + ); +}; + +interface TVToggleOptionRowProps { + label: string; + items: ToggleItem[]; + onToggle: (id: number) => void; + disabled?: boolean; +} + +export const TVToggleOptionRow: React.FC = ({ + label, + items, + onToggle, + disabled = false, +}) => { + if (items.length === 0) return null; + + return ( + + + {label} + + + {items.map((item) => ( + + ))} + + + ); +}; diff --git a/components/jellyseerr/tv/index.ts b/components/jellyseerr/tv/index.ts new file mode 100644 index 000000000..b20700c4b --- /dev/null +++ b/components/jellyseerr/tv/index.ts @@ -0,0 +1,4 @@ +export { TVJellyseerrPage } from "./TVJellyseerrPage"; +export { TVRequestModal } from "./TVRequestModal"; +export { TVRequestOptionRow } from "./TVRequestOptionRow"; +export { TVToggleOptionRow } from "./TVToggleOptionRow"; diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx index dde95e11e..791b0aa10 100644 --- a/components/tv/TVButton.tsx +++ b/components/tv/TVButton.tsx @@ -11,6 +11,7 @@ export interface TVButtonProps { style?: ViewStyle; scaleAmount?: number; square?: boolean; + refSetter?: (ref: View | null) => void; } const getButtonStyles = ( @@ -31,10 +32,12 @@ const getButtonStyles = ( }; case "secondary": return { - backgroundColor: focused ? "#7c3aed" : "rgba(124, 58, 237, 0.8)", - shadowColor: "#a855f7", - borderWidth: 1, - borderColor: "transparent", + backgroundColor: focused + ? "rgba(255, 255, 255, 0.3)" + : "rgba(255, 255, 255, 0.15)", + shadowColor: "#fff", + borderWidth: 2, + borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)", }; default: return { @@ -55,6 +58,7 @@ export const TVButton: React.FC = ({ style, scaleAmount = 1.05, square = false, + refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount }); @@ -63,6 +67,7 @@ export const TVButton: React.FC = ({ return ( Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 074/309] feat(tv): seerr --- CLAUDE.md | 5 + .../jellyseerr/page.tsx | 12 +- app/(auth)/(tabs)/(search)/index.tsx | 148 ++++++ app/(auth)/tv-option-modal.tsx | 2 +- app/(auth)/tv-request-modal.tsx | 489 ++++++++++++++++++ app/_layout.tsx | 8 + components/jellyseerr/discover/TVDiscover.tsx | 47 ++ .../jellyseerr/discover/TVDiscoverSlide.tsx | 249 +++++++++ .../search/TVJellyseerrSearchResults.tsx | 430 +++++++++++++++ components/search/TVSearchPage.tsx | 121 +++-- components/search/TVSearchTabBadges.tsx | 115 ++++ components/series/TVSeriesPage.tsx | 52 +- components/tv/TVOptionSelector.tsx | 2 +- hooks/useTVRequestModal.ts | 34 ++ translations/en.json | 2 + translations/sv.json | 4 +- utils/atoms/tvRequestModal.ts | 13 + 17 files changed, 1675 insertions(+), 58 deletions(-) create mode 100644 app/(auth)/tv-request-modal.tsx create mode 100644 components/jellyseerr/discover/TVDiscover.tsx create mode 100644 components/jellyseerr/discover/TVDiscoverSlide.tsx create mode 100644 components/search/TVJellyseerrSearchResults.tsx create mode 100644 components/search/TVSearchTabBadges.tsx create mode 100644 hooks/useTVRequestModal.ts create mode 100644 utils/atoms/tvRequestModal.ts diff --git a/CLAUDE.md b/CLAUDE.md index 858d15abe..10e4f5597 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,11 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - TV version uses `:tv` suffix for scripts - Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"` - Some features disabled on TV (e.g., notifications, Chromecast) +- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays. +- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. +- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. +- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. +- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference. ### TV Component Rendering Pattern diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index 2f0af5949..519d5e5cc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags"; import Cast from "@/components/jellyseerr/Cast"; import DetailFacts from "@/components/jellyseerr/DetailFacts"; import RequestModal from "@/components/jellyseerr/RequestModal"; +import { TVJellyseerrPage } from "@/components/jellyseerr/tv"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlatformDropdown } from "@/components/PlatformDropdown"; @@ -52,7 +53,8 @@ import type { } from "@/utils/jellyseerr/server/models/Search"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -const Page: React.FC = () => { +// Mobile page component +const MobilePage: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -542,4 +544,12 @@ const Page: React.FC = () => { ); }; +// Platform-conditional page component +const Page: React.FC = () => { + if (Platform.isTV) { + return ; + } + return ; +}; + export default Page; diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 43a90c4e5..831d96c1e 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -9,6 +9,7 @@ import axios from "axios"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import { useAtom } from "jotai"; +import { orderBy, uniqBy } from "lodash"; import { useCallback, useEffect, @@ -45,6 +46,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; import { createStreamystatsApi } from "@/utils/streamystats"; type SearchType = "Library" | "Discover"; @@ -452,6 +459,135 @@ export default function search() { [from, router], ); + // Jellyseerr search for TV + const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } = + useQuery({ + queryKey: ["search", "jellyseerr", "tv", debouncedSearch], + queryFn: async () => { + const params = { + query: new URLSearchParams(debouncedSearch || "").toString(), + }; + return await Promise.all([ + jellyseerrApi?.search({ ...params, page: 1 }), + jellyseerrApi?.search({ ...params, page: 2 }), + jellyseerrApi?.search({ ...params, page: 3 }), + jellyseerrApi?.search({ ...params, page: 4 }), + ]).then((all) => + uniqBy( + all.flatMap((v) => v?.results || []), + "id", + ), + ); + }, + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length > 0, + }); + + // Process Jellyseerr results for TV + const jellyseerrMovieResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.MOVIE, + ) as MovieResult[], + [(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTvResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.TV, + ) as TvResult[], + [(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrPersonResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === "person", + ) as PersonResult[], + [(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTVNoResults = useMemo(() => { + return ( + !jellyseerrMovieResults?.length && + !jellyseerrTvResults?.length && + !jellyseerrPersonResults?.length + ); + }, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]); + + // Fetch discover settings for TV (when no search query in Discover mode) + const { data: discoverSliders } = useQuery({ + queryKey: ["search", "jellyseerr", "discoverSettings", "tv"], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length === 0, + }); + + // TV Jellyseerr press handlers + const handleJellyseerrMoviePress = useCallback( + (item: MovieResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.title, + releaseYear: String(new Date(item.releaseDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.MOVIE, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrTvPress = useCallback( + (item: TvResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.name, + releaseYear: String(new Date(item.firstAirDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.TV, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrPersonPress = useCallback( + (item: PersonResult) => { + router.push(`/(auth)/jellyseerr/person/${item.id}` as any); + }, + [router], + ); + // Render TV search page if (Platform.isTV) { return ( @@ -471,6 +607,18 @@ export default function search() { loading={loading} noResults={noResults} onItemPress={handleItemPress} + searchType={searchType} + setSearchType={setSearchType} + showDiscover={!!jellyseerrApi} + jellyseerrMovies={jellyseerrMovieResults} + jellyseerrTv={jellyseerrTvResults} + jellyseerrPersons={jellyseerrPersonResults} + jellyseerrLoading={jellyseerrTVLoading} + jellyseerrNoResults={jellyseerrTVNoResults} + onJellyseerrMoviePress={handleJellyseerrMoviePress} + onJellyseerrTvPress={handleJellyseerrTvPress} + onJellyseerrPersonPress={handleJellyseerrPersonPress} + discoverSliders={discoverSliders} /> ); } diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index a21c5e328..330885a19 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -165,7 +165,7 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 48, - paddingVertical: 10, + paddingVertical: 20, gap: 12, }, }); diff --git a/app/(auth)/tv-request-modal.tsx b/app/(auth)/tv-request-modal.tsx new file mode 100644 index 000000000..685bb668a --- /dev/null +++ b/app/(auth)/tv-request-modal.tsx @@ -0,0 +1,489 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow"; +import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow"; +import { TVButton, TVOptionSelector } from "@/components/tv"; +import type { TVOptionItem } from "@/components/tv/TVOptionSelector"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { + QualityProfile, + RootFolder, + Tag, +} from "@/utils/jellyseerr/server/api/servarr/base"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +export default function TVRequestModalPage() { + const router = useRouter(); + const modalState = useAtomValue(tvRequestModalAtom); + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + + const [isReady, setIsReady] = useState(false); + const [requestOverrides, setRequestOverrides] = useState({ + mediaId: modalState?.id ? Number(modalState.id) : 0, + mediaType: modalState?.mediaType, + userId: jellyseerrUser?.id, + }); + + const [activeSelector, setActiveSelector] = useState< + "profile" | "folder" | "user" | null + >(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvRequestModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + const { data: serviceSettings } = useQuery({ + queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"], + queryFn: async () => + jellyseerrApi?.service( + modalState?.mediaType === "movie" ? "radarr" : "sonarr", + ), + enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState, + }); + + const { data: users } = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => + jellyseerrApi?.user({ take: 1000, sort: "displayname" }), + enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState, + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.((v) => v.isDefault), + [serviceSettings], + ); + + const { data: defaultServiceDetails } = useQuery({ + queryKey: [ + "jellyseerr", + "request", + modalState?.mediaType, + "service", + "details", + defaultService?.id, + ], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id, + })); + return jellyseerrApi?.serviceDetails( + modalState?.mediaType === "movie" ? "radarr" : "sonarr", + defaultService!.id, + ); + }, + enabled: + !!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState, + }); + + const defaultProfile: QualityProfile | undefined = useMemo( + () => + defaultServiceDetails?.profiles.find( + (p) => p.id === defaultServiceDetails.server?.activeProfileId, + ), + [defaultServiceDetails], + ); + + const defaultFolder: RootFolder | undefined = useMemo( + () => + defaultServiceDetails?.rootFolders.find( + (f) => f.path === defaultServiceDetails.server?.activeDirectory, + ), + [defaultServiceDetails], + ); + + const defaultTags: Tag[] = useMemo(() => { + return ( + defaultServiceDetails?.tags.filter((t) => + defaultServiceDetails?.server.activeTags?.includes(t.id), + ) ?? [] + ); + }, [defaultServiceDetails]); + + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + // Option builders + const qualityProfileOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.profiles.map((profile) => ({ + label: profile.name, + value: profile.id, + selected: + (requestOverrides.profileId || defaultProfile?.id) === profile.id, + })) || [], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + ], + ); + + const rootFolderOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.rootFolders.map((folder) => ({ + label: pathTitleExtractor(folder), + value: folder.path, + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === folder.path, + })) || [], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + ], + ); + + const userOptions: TVOptionItem[] = useMemo( + () => + users?.map((user) => ({ + label: user.displayName, + value: user.id, + selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id, + })) || [], + [users, jellyseerrUser, requestOverrides.userId], + ); + + const tagItems = useMemo(() => { + return ( + defaultServiceDetails?.tags.map((tag) => ({ + id: tag.id, + label: tag.label, + selected: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + })) ?? [] + ); + }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]); + + // Selected display values + const selectedProfileName = useMemo(() => { + const profile = defaultServiceDetails?.profiles.find( + (p) => p.id === (requestOverrides.profileId || defaultProfile?.id), + ); + return profile?.name || defaultProfile?.name || t("jellyseerr.select"); + }, [ + defaultServiceDetails?.profiles, + requestOverrides.profileId, + defaultProfile, + t, + ]); + + const selectedFolderName = useMemo(() => { + const folder = defaultServiceDetails?.rootFolders.find( + (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path), + ); + return folder + ? pathTitleExtractor(folder) + : defaultFolder + ? pathTitleExtractor(defaultFolder) + : t("jellyseerr.select"); + }, [ + defaultServiceDetails?.rootFolders, + requestOverrides.rootFolder, + defaultFolder, + t, + ]); + + const selectedUserName = useMemo(() => { + const user = users?.find( + (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id), + ); + return ( + user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select") + ); + }, [users, requestOverrides.userId, jellyseerrUser, t]); + + // Handlers + const handleProfileChange = useCallback((profileId: number) => { + setRequestOverrides((prev) => ({ ...prev, profileId })); + setActiveSelector(null); + }, []); + + const handleFolderChange = useCallback((rootFolder: string) => { + setRequestOverrides((prev) => ({ ...prev, rootFolder })); + setActiveSelector(null); + }, []); + + const handleUserChange = useCallback((userId: number) => { + setRequestOverrides((prev) => ({ ...prev, userId })); + setActiveSelector(null); + }, []); + + const handleTagToggle = useCallback( + (tagId: number) => { + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tagId); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tagId) + : [...currentTags, tagId], + }; + }); + }, + [defaultTags], + ); + + const handleRequest = useCallback(() => { + if (!modalState) return; + + const body = { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile?.id, + rootFolder: defaultFolder?.path, + tags: defaultTags.map((t) => t.id), + ...modalState.requestBody, + ...requestOverrides, + }; + + const seasonTitle = + modalState.requestBody?.seasons?.length === 1 + ? t("jellyseerr.season_number", { + season_number: modalState.requestBody.seasons[0], + }) + : modalState.requestBody?.seasons && + modalState.requestBody.seasons.length > 1 + ? t("jellyseerr.season_all") + : undefined; + + requestMedia( + seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title, + body, + () => { + modalState.onRequested(); + router.back(); + }, + ); + }, [ + modalState, + requestOverrides, + defaultProfile, + defaultFolder, + defaultTags, + defaultService, + defaultServiceDetails, + requestMedia, + router, + t, + ]); + + if (!modalState) { + return null; + } + + const isDataLoaded = defaultService && defaultServiceDetails && users; + + return ( + + + + + {t("jellyseerr.advanced")} + {modalState.title} + + {isDataLoaded && isReady ? ( + + + setActiveSelector("profile")} + hasTVPreferredFocus + /> + setActiveSelector("folder")} + /> + setActiveSelector("user")} + /> + + {tagItems.length > 0 && ( + + )} + + + ) : ( + + {t("common.loading")} + + )} + + {isReady && ( + + + + + {t("jellyseerr.request_button")} + + + + )} + + + + + {/* Sub-selectors */} + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + cardWidth={280} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + paddingHorizontal: 44, + overflow: "visible", + }, + heading: { + fontSize: TVTypography.heading, + fontWeight: "bold", + color: "#FFFFFF", + marginBottom: 8, + }, + subtitle: { + fontSize: TVTypography.callout, + color: "rgba(255,255,255,0.6)", + marginBottom: 24, + }, + scrollView: { + maxHeight: 320, + overflow: "visible", + }, + optionsContainer: { + gap: 12, + paddingVertical: 8, + paddingHorizontal: 4, + }, + loadingContainer: { + height: 200, + justifyContent: "center", + alignItems: "center", + }, + loadingText: { + color: "rgba(255,255,255,0.5)", + }, + buttonContainer: { + marginTop: 24, + }, + buttonText: { + fontSize: TVTypography.callout, + fontWeight: "bold", + color: "#FFFFFF", + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index bddb61208..64312a80a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -445,6 +445,14 @@ function Layout() { animation: "fade", }} /> + = ({ sliders }) => { + const sortedSliders = useMemo( + () => + sortBy( + (sliders ?? []).filter( + (s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type), + ), + "order", + "asc", + ), + [sliders], + ); + + if (!sliders || sortedSliders.length === 0) return null; + + return ( + + {sortedSliders.map((slide, index) => ( + + ))} + + ); +}; diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx new file mode 100644 index 000000000..876b96bb5 --- /dev/null +++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx @@ -0,0 +1,249 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { uniqBy } from "lodash"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { + type DiscoverEndpoint, + Endpoints, + useJellyseerr, +} from "@/hooks/useJellyseerr"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVDiscoverPosterProps { + item: MovieResult | TvResult; + isFirstItem?: boolean; +} + +const TVDiscoverPoster: React.FC = ({ + item, + isFirstItem = false, +}) => { + const router = useRouter(); + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + const handlePress = () => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + id: String(item.id), + mediaType: item.mediaType, + }, + }); + }; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVDiscoverSlideProps { + slide: DiscoverSlider; + isFirstSlide?: boolean; +} + +export const TVDiscoverSlide: React.FC = ({ + slide, + isFirstSlide = false, +}) => { + const { t } = useTranslation(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); + + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ["jellyseerr", "discover", "tv", slide.id], + queryFn: async ({ pageParam }) => { + let endpoint: DiscoverEndpoint | undefined; + let params: Record = { + page: Number(pageParam), + }; + + switch (slide.type) { + case DiscoverSliderType.TRENDING: + endpoint = Endpoints.DISCOVER_TRENDING; + break; + case DiscoverSliderType.POPULAR_MOVIES: + case DiscoverSliderType.UPCOMING_MOVIES: + endpoint = Endpoints.DISCOVER_MOVIES; + if (slide.type === DiscoverSliderType.UPCOMING_MOVIES) + params = { + ...params, + primaryReleaseDateGte: new Date().toISOString().split("T")[0], + }; + break; + case DiscoverSliderType.POPULAR_TV: + case DiscoverSliderType.UPCOMING_TV: + endpoint = Endpoints.DISCOVER_TV; + if (slide.type === DiscoverSliderType.UPCOMING_TV) + params = { + ...params, + firstAirDateGte: new Date().toISOString().split("T")[0], + }; + break; + } + + return endpoint ? jellyseerrApi?.discover(endpoint, params) : null; + }, + initialPageParam: 1, + getNextPageParam: (lastPage, pages) => + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + + 1, + enabled: !!jellyseerrApi, + staleTime: 0, + }); + + const flatData = useMemo( + () => + uniqBy( + data?.pages + ?.filter((p) => p?.results.length) + .flatMap((p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)), + ), + "id", + ) as (MovieResult | TvResult)[], + [data, isJellyseerrMovieOrTvResult], + ); + + const slideTitle = t( + `search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`, + ); + + if (!flatData || flatData.length === 0) return null; + + return ( + + + {slideTitle} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + onEndReached={() => { + if (hasNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.5} + renderItem={({ item, index }) => ( + + )} + /> + + ); +}; diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx new file mode 100644 index 000000000..dd0e55070 --- /dev/null +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -0,0 +1,430 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVJellyseerrPosterProps { + item: MovieResult | TvResult; + onPress: () => void; + isFirstItem?: boolean; +} + +const TVJellyseerrPoster: React.FC = ({ + item, + onPress, + isFirstItem = false, +}) => { + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVJellyseerrPersonPosterProps { + item: PersonResult; + onPress: () => void; +} + +const TVJellyseerrPersonPoster: React.FC = ({ + item, + onPress, +}) => { + const { jellyseerrApi } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.profilePath + ? jellyseerrApi?.imageProxy(item.profilePath, "w185") + : null; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {item.name} + + + + ); +}; + +interface TVJellyseerrMovieSectionProps { + title: string; + items: MovieResult[]; + isFirstSection?: boolean; + onItemPress: (item: MovieResult) => void; +} + +const TVJellyseerrMovieSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrTvSectionProps { + title: string; + items: TvResult[]; + isFirstSection?: boolean; + onItemPress: (item: TvResult) => void; +} + +const TVJellyseerrTvSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrPersonSectionProps { + title: string; + items: PersonResult[]; + isFirstSection?: boolean; + onItemPress: (item: PersonResult) => void; +} + +const TVJellyseerrPersonSection: React.FC = ({ + title, + items, + isFirstSection: _isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item }) => ( + onItemPress(item)} + /> + )} + /> + + ); +}; + +export interface TVJellyseerrSearchResultsProps { + movieResults: MovieResult[]; + tvResults: TvResult[]; + personResults: PersonResult[]; + loading: boolean; + noResults: boolean; + searchQuery: string; + onMoviePress: (item: MovieResult) => void; + onTvPress: (item: TvResult) => void; + onPersonPress: (item: PersonResult) => void; +} + +export const TVJellyseerrSearchResults: React.FC< + TVJellyseerrSearchResultsProps +> = ({ + movieResults, + tvResults, + personResults, + loading, + noResults, + searchQuery, + onMoviePress, + onTvPress, + onPersonPress, +}) => { + const { t } = useTranslation(); + + const hasMovies = movieResults && movieResults.length > 0; + const hasTv = tvResults && tvResults.length > 0; + const hasPersons = personResults && personResults.length > 0; + + if (loading) { + return null; + } + + if (noResults && searchQuery.length > 0) { + return ( + + + {t("search.no_results_found_for")} + + + "{searchQuery}" + + + ); + } + + return ( + + + + + + ); +}; diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 610b7a435..2212e3513 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -6,10 +6,18 @@ 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 { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { TVSearchBadge } from "./TVSearchBadge"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults"; import { TVSearchSection } from "./TVSearchSection"; +import { TVSearchTabBadges } from "./TVSearchTabBadges"; const HORIZONTAL_PADDING = 60; const TOP_PADDING = 100; @@ -77,20 +85,13 @@ const TVLoadingSkeleton: React.FC = () => { ); }; -// Example search suggestions for TV -const exampleSearches = [ - "Lord of the rings", - "Avengers", - "Game of Thrones", - "Breaking Bad", - "Stranger Things", - "The Mandalorian", -]; +type SearchType = "Library" | "Discover"; interface TVSearchPageProps { search: string; setSearch: (text: string) => void; debouncedSearch: string; + // Library search results movies?: BaseItemDto[]; series?: BaseItemDto[]; episodes?: BaseItemDto[]; @@ -103,6 +104,20 @@ interface TVSearchPageProps { loading: boolean; noResults: boolean; onItemPress: (item: BaseItemDto) => void; + // Jellyseerr/Discover props + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + jellyseerrMovies?: MovieResult[]; + jellyseerrTv?: TvResult[]; + jellyseerrPersons?: PersonResult[]; + jellyseerrLoading?: boolean; + jellyseerrNoResults?: boolean; + onJellyseerrMoviePress?: (item: MovieResult) => void; + onJellyseerrTvPress?: (item: TvResult) => void; + onJellyseerrPersonPress?: (item: PersonResult) => void; + // Discover sliders for empty state + discoverSliders?: DiscoverSlider[]; } export const TVSearchPage: React.FC = ({ @@ -121,6 +136,18 @@ export const TVSearchPage: React.FC = ({ loading, noResults, onItemPress, + searchType, + setSearchType, + showDiscover, + jellyseerrMovies = [], + jellyseerrTv = [], + jellyseerrPersons = [], + jellyseerrLoading = false, + jellyseerrNoResults = false, + onJellyseerrMoviePress, + onJellyseerrTvPress, + onJellyseerrPersonPress, + discoverSliders, }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -177,6 +204,11 @@ export const TVSearchPage: React.FC = ({ t, ]); + const isLibraryMode = searchType === "Library"; + const isDiscoverMode = searchType === "Discover"; + const currentLoading = isLibraryMode ? loading : jellyseerrLoading; + const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults; + return ( = ({ }} > {/* Search Input */} - + = ({ clearButtonMode='while-editing' maxLength={500} hasTVPreferredFocus={ - debouncedSearch.length === 0 && sections.length === 0 + debouncedSearch.length === 0 && + sections.length === 0 && + !showDiscover } /> + {/* Search Type Tab Badges */} + {showDiscover && ( + + + + )} + {/* Loading State */} - {loading && ( + {currentLoading && ( )} - {/* Search Results */} - {!loading && ( + {/* Library Search Results */} + {isLibraryMode && !loading && ( {sections.map((section, index) => ( = ({ )} + {/* Jellyseerr/Discover Search Results */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( + {})} + onTvPress={onJellyseerrTvPress || (() => {})} + onPersonPress={onJellyseerrPersonPress || (() => {})} + /> + )} + + {/* Discover Content (when no search query in Discover mode) */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && ( + + )} + {/* No Results State */} - {!loading && noResults && debouncedSearch.length > 0 && ( + {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( = ({ > {t("search.no_results_found_for")} - + "{debouncedSearch}" )} - - {/* Example Searches (when no search query) */} - {!loading && debouncedSearch.length === 0 && ( - - - {exampleSearches.map((example) => ( - setSearch(example)} - /> - ))} - - - )} ); }; diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx new file mode 100644 index 000000000..bce7390db --- /dev/null +++ b/components/search/TVSearchTabBadges.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; + +type SearchType = "Library" | "Discover"; + +interface TVSearchTabBadgeProps { + label: string; + isSelected: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const TVSearchTabBadge: React.FC = ({ + label, + isSelected, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + + // Design language: white for focused/selected, transparent white for unfocused + const getBackgroundColor = () => { + if (focused) return "#fff"; + if (isSelected) return "rgba(255,255,255,0.25)"; + return "rgba(255,255,255,0.1)"; + }; + + const getTextColor = () => { + if (focused) return "#000"; + return "#fff"; + }; + + return ( + + + + {label} + + + + ); +}; + +export interface TVSearchTabBadgesProps { + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + disabled?: boolean; +} + +export const TVSearchTabBadges: React.FC = ({ + searchType, + setSearchType, + showDiscover, + disabled = false, +}) => { + if (!showDiscover) { + return null; + } + + return ( + + setSearchType("Library")} + disabled={disabled} + /> + setSearchType("Discover")} + disabled={disabled} + /> + + ); +}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 23a78b9ab..0f5daf5c1 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -32,9 +32,9 @@ import { TVEpisodeCard, } from "@/components/series/TVEpisodeCard"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; +import { TVOptionSelector } from "@/components/tv/TVOptionSelector"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; -import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; @@ -229,8 +229,8 @@ export const TVSeriesPage: React.FC = ({ [item.Id, seasonIndexState], ); - // TV option modal hook - const { showOptions } = useTVOptionModal(); + // Season selector modal state + const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); // ScrollView ref for page scrolling const mainScrollRef = useRef(null); @@ -403,22 +403,24 @@ export const TVSeriesPage: React.FC = ({ // Open season modal const handleOpenSeasonModal = useCallback(() => { - const options = seasons.map((season: BaseItemDto) => ({ + setIsSeasonModalVisible(true); + }, []); + + // Close season modal + const handleCloseSeasonModal = useCallback(() => { + setIsSeasonModalVisible(false); + }, []); + + // Season options for the modal + const seasonOptions = useMemo(() => { + return seasons.map((season: BaseItemDto) => ({ label: season.Name || `Season ${season.IndexNumber}`, value: season.IndexNumber ?? 0, selected: season.IndexNumber === selectedSeasonIndex || season.Name === String(selectedSeasonIndex), })); - - showOptions({ - title: t("item_card.select_season"), - options, - onSelect: handleSeasonSelect, - cardWidth: 180, - cardHeight: 85, - }); - }, [seasons, selectedSeasonIndex, showOptions, t, handleSeasonSelect]); + }, [seasons, selectedSeasonIndex]); // Episode list item layout const getItemLayout = useCallback( @@ -439,10 +441,16 @@ export const TVSeriesPage: React.FC = ({ onPress={() => handleEpisodePress(episode)} onFocus={handleEpisodeFocus} onBlur={handleEpisodeBlur} + disabled={isSeasonModalVisible} /> ), - [handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur], + [ + handleEpisodePress, + handleEpisodeFocus, + handleEpisodeBlur, + isSeasonModalVisible, + ], ); // Get play button text @@ -563,7 +571,8 @@ export const TVSeriesPage: React.FC = ({ > = ({ )} @@ -638,6 +648,18 @@ export const TVSeriesPage: React.FC = ({ /> + + {/* Season selector modal */} + ); }; diff --git a/components/tv/TVOptionSelector.tsx b/components/tv/TVOptionSelector.tsx index 2e6f34be0..0d260c347 100644 --- a/components/tv/TVOptionSelector.tsx +++ b/components/tv/TVOptionSelector.tsx @@ -189,7 +189,7 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 48, - paddingVertical: 10, + paddingVertical: 20, gap: 12, }, cancelButtonContainer: { diff --git a/hooks/useTVRequestModal.ts b/hooks/useTVRequestModal.ts new file mode 100644 index 000000000..0c096bb46 --- /dev/null +++ b/hooks/useTVRequestModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +interface ShowRequestModalParams { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} + +export const useTVRequestModal = () => { + const router = useRouter(); + + const showRequestModal = useCallback( + (params: ShowRequestModalParams) => { + store.set(tvRequestModalAtom, { + requestBody: params.requestBody, + title: params.title, + id: params.id, + mediaType: params.mediaType, + onRequested: params.onRequested, + }); + router.push("/(auth)/tv-request-modal"); + }, + [router], + ); + + return { showRequestModal }; +}; diff --git a/translations/en.json b/translations/en.json index 1aa4f114a..9481f39d9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -754,6 +754,8 @@ "decline": "Decline", "requested_by": "Requested by {{user}}", "unknown_user": "Unknown User", + "select": "Select", + "request_all": "Request All", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Seerr test failed. Please try again.", diff --git a/translations/sv.json b/translations/sv.json index e6db40046..483be9716 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -709,7 +709,7 @@ "quality_profile": "Kvalitetsprofil", "root_folder": "Rotkatalog", "season_all": "Säsong (alla)", - "season_number": "Säsong {{seasonNumber}}", + "season_number": "Säsong {{season_number}}", "number_episodes": "{{episode_number}} Avsnitt", "born": "Född", "appearances": "Framträdanden", @@ -717,6 +717,8 @@ "decline": "Avvisa", "requested_by": "Begärt av {{user}}", "unknown_user": "Okänd användare", + "select": "Välj", + "request_all": "Begär alla", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0", "jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.", diff --git a/utils/atoms/tvRequestModal.ts b/utils/atoms/tvRequestModal.ts new file mode 100644 index 000000000..a1cd6ea7f --- /dev/null +++ b/utils/atoms/tvRequestModal.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; + +export type TVRequestModalState = { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} | null; + +export const tvRequestModalAtom = atom(null); From 2a9f4c2885d929b1baae4bfdbbcc04ef1421cc03 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 075/309] fix: design --- .../jellyseerr/discover/TVDiscoverSlide.tsx | 49 ++- components/jellyseerr/tv/TVJellyseerrPage.tsx | 247 +++++++------- .../search/TVJellyseerrSearchResults.tsx | 45 ++- components/series/TVEpisodeCard.tsx | 4 + components/series/TVSeriesPage.tsx | 100 +++--- components/tv/TVButton.tsx | 6 + components/tv/TVFocusablePoster.tsx | 12 +- docs/tv-focus-guide.md | 317 +++++++++++------- 8 files changed, 457 insertions(+), 323 deletions(-) diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx index 876b96bb5..6d750fea5 100644 --- a/components/jellyseerr/discover/TVDiscoverSlide.tsx +++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx @@ -15,6 +15,7 @@ import { useJellyseerr, } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type { MovieResult, @@ -35,7 +36,7 @@ const TVDiscoverPoster: React.FC = ({ const router = useRouter(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation({ scaleAmount: 1.05 }); const posterUrl = item.posterPath ? jellyseerrApi?.imageProxy(item.posterPath, "w342") @@ -44,6 +45,10 @@ const TVDiscoverPoster: React.FC = ({ const title = getTitle(item); const year = getYear(item); + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + const handlePress = () => { router.push({ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", @@ -65,23 +70,21 @@ const TVDiscoverPoster: React.FC = ({ style={[ animatedStyle, { - width: 180, + width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.4 : 0, - shadowRadius: focused ? 12 : 0, + shadowOpacity: focused ? 0.6 : 0, + shadowRadius: focused ? 20 : 0, }, ]} > {posterUrl ? ( @@ -107,13 +110,30 @@ const TVDiscoverPoster: React.FC = ({ /> )} + {isInLibrary && ( + + + + )} @@ -122,10 +142,9 @@ const TVDiscoverPoster: React.FC = ({ {year && ( {year} diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx index f36609729..e81ca4dbc 100644 --- a/components/jellyseerr/tv/TVJellyseerrPage.tsx +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next"; import { Animated, Dimensions, - FlatList, Pressable, ScrollView, TVFocusGuideView, @@ -61,7 +60,6 @@ interface TVCastCardProps { }; imageProxy: (path: string, size?: string) => string; onPress: () => void; - isFirst?: boolean; refSetter?: (ref: View | null) => void; } @@ -69,7 +67,6 @@ const TVCastCard: React.FC = ({ person, imageProxy, onPress, - isFirst, refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = @@ -85,7 +82,6 @@ const TVCastCard: React.FC = ({ onPress={onPress} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={isFirst} > void; + refSetter?: (ref: View | null) => void; } const TVSeasonCard: React.FC = ({ @@ -182,6 +179,7 @@ const TVSeasonCard: React.FC = ({ canRequest, disabled = false, onCardFocus, + refSetter, }) => { const { t } = useTranslation(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -194,6 +192,7 @@ const TVSeasonCard: React.FC = ({ return ( = ({ animatedStyle, { minWidth: 180, - padding: 16, + paddingVertical: 18, + paddingHorizontal: 32, backgroundColor: focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.08)", @@ -221,42 +221,46 @@ const TVSeasonCard: React.FC = ({ }, ]} > - + + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + - {t("jellyseerr.season_number", { - season_number: season.seasonNumber, + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, })} - - - {t("jellyseerr.number_episodes", { - episode_number: season.episodeCount, - })} - ); @@ -279,9 +283,10 @@ export const TVJellyseerrPage: React.FC = () => { const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { showRequestModal } = useTVRequestModal(); - const [lastActionButtonRef, setLastActionButtonRef] = useState( - null, - ); + + // Refs for TVFocusGuideView destinations (useState triggers re-render when set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); // Scroll control ref const mainScrollRef = useRef(null); @@ -757,7 +762,7 @@ export const TVJellyseerrPage: React.FC = () => { onPress={handlePlay} hasTVPreferredFocus variant='primary' - refSetter={!canRequest ? setLastActionButtonRef : undefined} + refSetter={setPlayButtonRef} > { )} - {canRequest && ( + {/* Request button - only show for movies, TV series use Request All + season cards */} + {canRequest && mediaType === MediaType.MOVIE && ( { )} + + {/* Request All button for TV series */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + !allSeasonsAvailable && ( + + + + + {t("jellyseerr.request_all")} + + + + )} + + {/* Individual season cards for TV series */} + {mediaType === MediaType.TV && + orderBy( + seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "desc", + ).map((season) => { + const canRequestSeason = + season.status === MediaStatus.UNKNOWN; + return ( + handleSeasonRequest(season.seasonNumber)} + canRequest={canRequestSeason} + onCardFocus={handleSeasonsFocus} + /> + ); + })} {/* Approve/Decline for managers */} @@ -867,67 +929,6 @@ export const TVJellyseerrPage: React.FC = () => { - {/* Seasons section (TV shows only) */} - {mediaType === MediaType.TV && - seasons.filter((s) => s.seasonNumber !== 0).length > 0 && ( - - - {t("item_card.seasons")} - - - - {!allSeasonsAvailable && ( - - - - {t("jellyseerr.request_all")} - - - )} - {orderBy( - seasons.filter((s) => s.seasonNumber !== 0), - "seasonNumber", - "desc", - ).map((season) => { - const canRequestSeason = - season.status === MediaStatus.UNKNOWN; - return ( - handleSeasonRequest(season.seasonNumber)} - canRequest={canRequestSeason} - onCardFocus={handleSeasonsFocus} - /> - ); - })} - - - )} - {/* Cast section */} {cast.length > 0 && jellyseerrApi && ( @@ -942,35 +943,51 @@ export const TVJellyseerrPage: React.FC = () => { {t("jellyseerr.cast")} - {/* Focus guide for upward navigation from cast to action buttons */} - {lastActionButtonRef && ( + {/* Focus guides for bidirectional navigation - stacked together */} + {/* Downward: action buttons → first cast card */} + {firstCastCardRef && ( + )} + {/* Upward: cast → action buttons */} + {playButtonRef && ( + )} - item.id.toString()} showsHorizontalScrollIndicator={false} + style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: 16, gap: 28, }} - style={{ overflow: "visible" }} - renderItem={({ item, index }) => ( + > + {cast.map((person, index) => ( jellyseerrApi.imageProxy(path, size || "w185") } - onPress={() => handleCastPress(item.id)} - isFirst={index === 0} + onPress={() => handleCastPress(person.id)} + refSetter={index === 0 ? setFirstCastCardRef : undefined} /> - )} - /> + ))} + )} diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx index dd0e55070..d696e9d4a 100644 --- a/components/search/TVJellyseerrSearchResults.tsx +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -7,6 +7,7 @@ import { Text } from "@/components/common/Text"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { TVTypography } from "@/constants/TVTypography"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import type { MovieResult, PersonResult, @@ -28,7 +29,7 @@ const TVJellyseerrPoster: React.FC = ({ }) => { const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation({ scaleAmount: 1.05 }); const posterUrl = item.posterPath ? jellyseerrApi?.imageProxy(item.posterPath, "w342") @@ -37,6 +38,10 @@ const TVJellyseerrPoster: React.FC = ({ const title = getTitle(item); const year = getYear(item); + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + return ( = ({ width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.4 : 0, - shadowRadius: focused ? 12 : 0, + shadowOpacity: focused ? 0.6 : 0, + shadowRadius: focused ? 20 : 0, }, ]} > {posterUrl ? ( @@ -90,13 +93,30 @@ const TVJellyseerrPoster: React.FC = ({ /> )} + {isInLibrary && ( + + + + )} @@ -105,10 +125,9 @@ const TVJellyseerrPoster: React.FC = ({ {year && ( {year} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 95bd1c6e2..8cfb935d4 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -20,6 +20,8 @@ interface TVEpisodeCardProps { onPress: () => void; onFocus?: () => void; onBlur?: () => void; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; } export const TVEpisodeCard: React.FC = ({ @@ -29,6 +31,7 @@ export const TVEpisodeCard: React.FC = ({ onPress, onFocus, onBlur, + refSetter, }) => { const api = useAtomValue(apiAtom); @@ -71,6 +74,7 @@ export const TVEpisodeCard: React.FC = ({ disabled={disabled} onFocus={onFocus} onBlur={onBlur} + refSetter={refSetter} > void; }> = ({ onPress, children, hasTVPreferredFocus, disabled = false, variant = "primary", + refSetter, }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -86,6 +85,7 @@ const TVFocusableButton: React.FC<{ return ( { setFocused(true); @@ -232,17 +232,21 @@ export const TVSeriesPage: React.FC = ({ // Season selector modal state const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); + // Focus guide refs (using useState to trigger re-renders when refs are set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); + // ScrollView ref for page scrolling const mainScrollRef = useRef(null); - // FlatList ref for scrolling back - const episodeListRef = useRef>(null); + // ScrollView ref for scrolling back + const episodeListRef = useRef(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); // Scroll back to start when episode list loses focus useEffect(() => { if (prevFocusedCount.current > 0 && focusedCount === 0) { - episodeListRef.current?.scrollToOffset({ offset: 0, animated: true }); + episodeListRef.current?.scrollTo({ x: 0, animated: true }); // Scroll page back to top when leaving episode section mainScrollRef.current?.scrollTo({ y: 0, animated: true }); } @@ -422,37 +426,6 @@ export const TVSeriesPage: React.FC = ({ })); }, [seasons, selectedSeasonIndex]); - // Episode list item layout - const getItemLayout = useCallback( - (_data: ArrayLike | null | undefined, index: number) => ({ - length: TV_EPISODE_WIDTH + ITEM_GAP, - offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index, - index, - }), - [], - ); - - // Render episode card - const renderEpisode = useCallback( - ({ item: episode }: { item: BaseItemDto; index: number }) => ( - - handleEpisodePress(episode)} - onFocus={handleEpisodeFocus} - onBlur={handleEpisodeBlur} - disabled={isSeasonModalVisible} - /> - - ), - [ - handleEpisodePress, - handleEpisodeFocus, - handleEpisodeBlur, - isSeasonModalVisible, - ], - ); - // Get play button text const playButtonText = useMemo(() => { if (!nextUnwatchedEpisode) return t("common.play"); @@ -574,6 +547,7 @@ export const TVSeriesPage: React.FC = ({ hasTVPreferredFocus={!isSeasonModalVisible} disabled={isSeasonModalVisible} variant='primary' + refSetter={setPlayButtonRef} > = ({ {selectedSeasonName} - + )} + {/* Upward: episodes → Play button */} + {playButtonRef && ( + + )} + + ep.Id!} - renderItem={renderEpisode} showsHorizontalScrollIndicator={false} - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={5} - removeClippedSubviews={false} - getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, + gap: ITEM_GAP, }} - ListEmptyComponent={ + > + {episodesForSeason.length > 0 ? ( + episodesForSeason.map((episode, index) => ( + handleEpisodePress(episode)} + onFocus={handleEpisodeFocus} + onBlur={handleEpisodeBlur} + disabled={isSeasonModalVisible} + // Pass refSetter to first episode for focus guide destination + // Note: Do NOT use hasTVPreferredFocus on focus guide destinations + refSetter={index === 0 ? setFirstEpisodeRef : undefined} + /> + )) + ) : ( = ({ > {t("item_card.no_episodes_for_this_season")} - } - /> + )} + diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx index 791b0aa10..0522ef5f6 100644 --- a/components/tv/TVButton.tsx +++ b/components/tv/TVButton.tsx @@ -12,6 +12,8 @@ export interface TVButtonProps { scaleAmount?: number; square?: boolean; refSetter?: (ref: View | null) => void; + nextFocusDown?: number; + nextFocusUp?: number; } const getButtonStyles = ( @@ -59,6 +61,8 @@ export const TVButton: React.FC = ({ scaleAmount = 1.05, square = false, refSetter, + nextFocusDown, + nextFocusUp, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount }); @@ -74,6 +78,8 @@ export const TVButton: React.FC = ({ hasTVPreferredFocus={hasTVPreferredFocus && !disabled} disabled={disabled} focusable={!disabled} + nextFocusDown={nextFocusDown} + nextFocusUp={nextFocusUp} > void; onBlur?: () => void; disabled?: boolean; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; } export const TVFocusablePoster: React.FC = ({ @@ -23,6 +31,7 @@ export const TVFocusablePoster: React.FC = ({ onFocus: onFocusProp, onBlur: onBlurProp, disabled = false, + refSetter, }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -39,6 +48,7 @@ export const TVFocusablePoster: React.FC = ({ return ( { setFocused(true); diff --git a/docs/tv-focus-guide.md b/docs/tv-focus-guide.md index 55ce627ec..fc49a93e8 100644 --- a/docs/tv-focus-guide.md +++ b/docs/tv-focus-guide.md @@ -2,6 +2,54 @@ This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV. +## Platform Differences (CRITICAL) + +### tvOS vs Android TV + +**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.** + +This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native. + +```typescript +// ❌ Does NOT work on tvOS (Apple TV) + + ... + + +// ✅ Works on both tvOS and Android TV + + ... + +``` + +**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.** + +## ScrollView vs FlatList for TV + +**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.** + +FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior. + +```typescript +// ❌ FlatList can cause focus issues on TV + } +/> + +// ✅ ScrollView provides reliable focus navigation + + {cast.map((person, index) => ( + + ))} + +``` + +**When to use which:** +- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable +- **FlatList**: Large lists where performance is more important than perfect focus navigation + ## The Problem tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when: @@ -53,159 +101,160 @@ const [targetRef, setTargetRef] = useState(null); ``` -## Complete Example: Bidirectional Navigation +## Bidirectional Navigation (CRITICAL PATTERN) -This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards. +When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element. -### Step 1: Convert Components to forwardRef +### The Focus Flickering Problem -Any component that needs to be a focus destination must forward its ref: +If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements. ```typescript -const TVOptionButton = React.forwardRef< - View, - { - label: string; - onPress: () => void; - } ->(({ label, onPress }, ref) => { - return ( - - {label} - - ); -}); +// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + -const TVActorCard = React.forwardRef< - View, - { - name: string; - onPress: () => void; - } ->(({ name, onPress }, ref) => { - return ( - - {name} - - ); -}); +// ✅ CORRECT - destination does NOT have hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + ``` -### Step 2: Track Refs with State +### Complete Bidirectional Example ```typescript const MyScreen: React.FC = () => { - // Track the first actor card (for downward navigation) - const [firstActorRef, setFirstActorRef] = useState(null); + // Track refs for focus navigation + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); - // Track the last option button (for upward navigation) - const [lastButtonRef, setLastButtonRef] = useState(null); + return ( + + {/* Action buttons section */} + + + Play + + - // ... + {/* Cast section */} + + Cast + + {/* BOTH focus guides stacked together, above the list */} + {/* Downward: Play button → first cast card */} + {firstCastCardRef && ( + + )} + {/* Upward: cast → Play button */} + {playButtonRef && ( + + )} + + {/* Use ScrollView, not FlatList, for reliable focus */} + + {cast.map((person, index) => ( + + ))} + + + + ); }; ``` -### Step 3: Place Focus Guides +### Key Rules for Bidirectional Navigation -```typescript -return ( - - {/* Option buttons */} - - - - - - - {/* Focus guide: options → cast (downward navigation) */} - {firstActorRef && ( - - )} - - {/* Cast section */} - - Cast - - {/* Focus guide: cast → options (upward navigation) */} - {lastButtonRef && ( - - )} - - - {actors.map((actor, index) => ( - - ))} - - - -); -``` - -### Step 4: Handle Dynamic "Last" Element - -When the last button varies based on conditions (e.g., subtitle button only shows if subtitles exist), compute which one is last: - -```typescript -// Determine which button is last -const lastOptionButton = useMemo(() => { - if (hasSubtitles) return "subtitle"; - if (hasAudio) return "audio"; - return "quality"; -}, [hasSubtitles, hasAudio]); - -// Pass ref only to the last one - - - -``` +1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list +2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering +3. **Use ScrollView instead of FlatList** - More reliable focus behavior +4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set ## Focus Guide Placement -The focus guide should be placed **between** the source and destination sections: +The focus guides should be placed **together** above the destination section: ``` ┌─────────────────────────┐ -│ Option Buttons │ ← Source (going down) -│ [Quality] [Audio] │ +│ Action Buttons │ ← Source (going down) +│ [Play] [Request] │ Has hasTVPreferredFocus ✓ └─────────────────────────┘ + ↓ ┌─────────────────────────┐ -│ TVFocusGuideView │ ← Invisible guide (height: 1px) -│ destinations=[actor1] │ Catches downward navigation -└─────────────────────────┘ -┌─────────────────────────┐ -│ TVFocusGuideView │ ← Invisible guide (height: 1px) -│ destinations=[lastBtn] │ Catches upward navigation +│ TVFocusGuideView │ ← Downward guide +│ destinations=[card1] │ ├─────────────────────────┤ -│ Actor Cards │ ← Destination (going down) -│ [👤] [👤] [👤] [👤] │ Source (going up) +│ TVFocusGuideView │ ← Upward guide +│ destinations=[playBtn] │ (stacked together) └─────────────────────────┘ + ↓ +┌─────────────────────────┐ +│ Cast Cards (ScrollView)│ ← First card is destination +│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗ +└─────────────────────────┘ +``` + +## Component Pattern with refSetter + +For components that need to be focus guide destinations, use a `refSetter` callback prop: + +```typescript +interface TVCastCardProps { + person: { id: number; name: string }; + onPress: () => void; + refSetter?: (ref: View | null) => void; +} + +const TVCastCard: React.FC = ({ + person, + onPress, + refSetter, +}) => { + return ( + + {person.name} + + ); +}; + +// Usage + ``` ## Tips and Gotchas @@ -232,13 +281,25 @@ The focus guide should be placed **between** the source and destination sections ``` -5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child: +5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region: ```typescript {/* First focusable child will receive focus */} ``` + **Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation. + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` | +| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView | +| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination | +| Focus guides placed separately | Focus flickering | Stack both guides together | +| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` | + ## Reference Implementation -See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list. +See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list. From a8acdf4299d626e195d44dab81c514d36617fe90 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 076/309] feat(tv): hide music and playlists libraries on tv --- components/library/TVLibraries.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index b98ffa095..d7e445a01 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -254,7 +254,9 @@ export const TVLibraries: React.FC = () => { () => userViews ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) - .filter((l) => l.CollectionType !== "books") || [], + .filter((l) => l.CollectionType !== "books") + .filter((l) => l.CollectionType !== "music") + .filter((l) => l.CollectionType !== "playlists") || [], [userViews, settings?.hiddenLibraries], ); From 506d8b14dc015990e47df50b78db8d6bfaef6657 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 077/309] fix(tv): wrap actor page in scrollview to fix focus navigation between sections --- app/(auth)/tv-season-select-modal.tsx | 443 ++++++++++++++++++ app/_layout.tsx | 8 + components/jellyseerr/tv/TVJellyseerrPage.tsx | 250 +++------- components/persons/TVActorPage.tsx | 8 +- hooks/useTVSeasonSelectModal.ts | 23 + translations/en.json | 4 + utils/atoms/tvSeasonSelectModal.ts | 18 + 7 files changed, 566 insertions(+), 188 deletions(-) create mode 100644 app/(auth)/tv-season-select-modal.tsx create mode 100644 hooks/useTVSeasonSelectModal.ts create mode 100644 utils/atoms/tvSeasonSelectModal.ts diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx new file mode 100644 index 000000000..09b46cc55 --- /dev/null +++ b/app/(auth)/tv-season-select-modal.tsx @@ -0,0 +1,443 @@ +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { orderBy } from "lodash"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + Pressable, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVButton } from "@/components/tv"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVRequestModal } from "@/hooks/useTVRequestModal"; +import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal"; +import { + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +interface TVSeasonToggleCardProps { + season: { + id: number; + seasonNumber: number; + episodeCount: number; + status: MediaStatus; + }; + selected: boolean; + onToggle: () => void; + canRequest: boolean; + hasTVPreferredFocus?: boolean; +} + +const TVSeasonToggleCard: React.FC = ({ + season, + selected, + onToggle, + canRequest, + hasTVPreferredFocus, +}) => { + const { t } = useTranslation(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + // Get status icon and color based on MediaStatus + const getStatusIcon = (): { + icon: keyof typeof MaterialCommunityIcons.glyphMap; + color: string; + } | null => { + switch (season.status) { + case MediaStatus.PROCESSING: + return { icon: "clock", color: "#6366f1" }; + case MediaStatus.AVAILABLE: + return { icon: "check", color: "#22c55e" }; + case MediaStatus.PENDING: + return { icon: "bell", color: "#eab308" }; + case MediaStatus.PARTIALLY_AVAILABLE: + return { icon: "minus", color: "#22c55e" }; + case MediaStatus.BLACKLISTED: + return { icon: "eye-off", color: "#ef4444" }; + default: + return canRequest ? { icon: "plus", color: "#22c55e" } : null; + } + }; + + const statusInfo = getStatusIcon(); + const isDisabled = !canRequest; + + return ( + + + {/* Checkmark for selected */} + + {selected && ( + + )} + + + {/* Season info */} + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, + })} + + {statusInfo && ( + + + + )} + + + + + ); +}; + +export default function TVSeasonSelectModalPage() { + const router = useRouter(); + const modalState = useAtomValue(tvSeasonSelectModalAtom); + const { t } = useTranslation(); + const { requestMedia } = useJellyseerr(); + const { showRequestModal } = useTVRequestModal(); + + // Selected seasons - initially select all requestable (UNKNOWN status) seasons + const [selectedSeasons, setSelectedSeasons] = useState>( + new Set(), + ); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Initialize selected seasons when modal state changes + useEffect(() => { + if (modalState?.seasons) { + const requestableSeasons = modalState.seasons + .filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) + .map((s) => s.seasonNumber); + setSelectedSeasons(new Set(requestableSeasons)); + } + }, [modalState?.seasons]); + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + return () => { + store.set(tvSeasonSelectModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + // Sort seasons by season number (ascending) + const sortedSeasons = useMemo(() => { + if (!modalState?.seasons) return []; + return orderBy( + modalState.seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "asc", + ); + }, [modalState?.seasons]); + + // Find the index of the first requestable season for initial focus + const firstRequestableIndex = useMemo(() => { + return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN); + }, [sortedSeasons]); + + const handleToggleSeason = useCallback((seasonNumber: number) => { + setSelectedSeasons((prev) => { + const newSet = new Set(prev); + if (newSet.has(seasonNumber)) { + newSet.delete(seasonNumber); + } else { + newSet.add(seasonNumber); + } + return newSet; + }); + }, []); + + const handleRequestSelected = useCallback(() => { + if (!modalState || selectedSeasons.size === 0) return; + + const seasonsArray = Array.from(selectedSeasons); + const body: MediaRequestBody = { + mediaId: modalState.mediaId, + mediaType: MediaType.TV, + tvdbId: modalState.tvdbId, + seasons: seasonsArray, + }; + + if (modalState.hasAdvancedRequestPermission) { + // Close this modal and open the advanced request modal + router.back(); + showRequestModal({ + requestBody: body, + title: modalState.title, + id: modalState.mediaId, + mediaType: MediaType.TV, + onRequested: modalState.onRequested, + }); + return; + } + + // Build the title based on selected seasons + const seasonTitle = + seasonsArray.length === 1 + ? t("jellyseerr.season_number", { season_number: seasonsArray[0] }) + : seasonsArray.length === sortedSeasons.length + ? t("jellyseerr.season_all") + : t("jellyseerr.n_selected", { count: seasonsArray.length }); + + requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => { + modalState.onRequested(); + router.back(); + }); + }, [ + modalState, + selectedSeasons, + sortedSeasons.length, + requestMedia, + router, + t, + showRequestModal, + ]); + + if (!modalState) { + return null; + } + + return ( + + + + + {t("jellyseerr.select_seasons")} + {modalState.title} + + {/* Season cards horizontal scroll */} + + {sortedSeasons.map((season, index) => { + const canRequestSeason = season.status === MediaStatus.UNKNOWN; + return ( + handleToggleSeason(season.seasonNumber)} + canRequest={canRequestSeason} + hasTVPreferredFocus={index === firstRequestableIndex} + /> + ); + })} + + + {/* Request button */} + + + + + {t("jellyseerr.request_selected")} + {selectedSeasons.size > 0 && ` (${selectedSeasons.size})`} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + paddingHorizontal: 44, + overflow: "visible", + }, + heading: { + fontSize: TVTypography.heading, + fontWeight: "bold", + color: "#FFFFFF", + marginBottom: 8, + }, + subtitle: { + fontSize: TVTypography.callout, + color: "rgba(255,255,255,0.6)", + marginBottom: 24, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingVertical: 12, + paddingHorizontal: 4, + gap: 16, + }, + seasonCard: { + width: 160, + paddingVertical: 16, + paddingHorizontal: 16, + borderRadius: 12, + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.2, + shadowRadius: 8, + }, + checkmarkContainer: { + height: 24, + marginBottom: 8, + }, + seasonInfo: { + flex: 1, + }, + seasonTitle: { + fontSize: TVTypography.callout, + fontWeight: "600", + marginBottom: 4, + }, + episodeRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + episodeCount: { + fontSize: 14, + }, + statusBadge: { + width: 22, + height: 22, + borderRadius: 11, + justifyContent: "center", + alignItems: "center", + }, + buttonContainer: { + marginTop: 24, + }, + buttonText: { + fontSize: TVTypography.callout, + fontWeight: "bold", + color: "#FFFFFF", + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 64312a80a..9a377803a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -453,6 +453,14 @@ function Layout() { animation: "fade", }} /> + = ({ ); }; -// Season card component -interface TVSeasonCardProps { - season: { - id: number; - seasonNumber: number; - episodeCount: number; - status: MediaStatus; - }; - onPress: () => void; - canRequest: boolean; - disabled?: boolean; - onCardFocus?: () => void; - refSetter?: (ref: View | null) => void; -} - -const TVSeasonCard: React.FC = ({ - season, - onPress, - canRequest, - disabled = false, - onCardFocus, - refSetter, -}) => { - const { t } = useTranslation(); - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05 }); - - const handleCardFocus = useCallback(() => { - handleFocus(); - onCardFocus?.(); - }, [handleFocus, onCardFocus]); - - return ( - - - - - - {t("jellyseerr.season_number", { - season_number: season.seasonNumber, - })} - - - - - {t("jellyseerr.number_episodes", { - episode_number: season.episodeCount, - })} - - - - - ); -}; - export const TVJellyseerrPage: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); @@ -283,15 +174,12 @@ export const TVJellyseerrPage: React.FC = () => { const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { showRequestModal } = useTVRequestModal(); + const { showSeasonSelectModal } = useTVSeasonSelectModal(); // Refs for TVFocusGuideView destinations (useState triggers re-render when set) const [playButtonRef, setPlayButtonRef] = useState(null); const [firstCastCardRef, setFirstCastCardRef] = useState(null); - // Scroll control ref - const mainScrollRef = useRef(null); - const scrollPositionRef = useRef(0); - const { data: details, isFetching, @@ -352,11 +240,21 @@ export const TVJellyseerrPage: React.FC = () => { ); }, [details, mediaType]); - const allSeasonsAvailable = useMemo( + const _allSeasonsAvailable = useMemo( () => seasons.every((season) => season.status === MediaStatus.AVAILABLE), [seasons], ); + // Check if there are any requestable seasons (status === UNKNOWN) + const hasRequestableSeasons = useMemo( + () => + seasons.some( + (season) => + season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN, + ), + [seasons], + ); + // Get cast const cast = useMemo(() => { return details?.credits?.cast?.slice(0, 10) ?? []; @@ -435,43 +333,6 @@ export const TVJellyseerrPage: React.FC = () => { showRequestModal, ]); - const handleSeasonRequest = useCallback( - (seasonNumber: number) => { - const body: MediaRequestBody = { - mediaId: Number(result.id!), - mediaType: MediaType.TV, - tvdbId: details?.externalIds?.tvdbId, - seasons: [seasonNumber], - }; - - if (hasAdvancedRequestPermission) { - showRequestModal({ - requestBody: body, - title: mediaTitle, - id: result.id!, - mediaType: MediaType.TV, - onRequested: refetch, - }); - return; - } - - const seasonTitle = t("jellyseerr.season_number", { - season_number: seasonNumber, - }); - requestMedia(`${mediaTitle}, ${seasonTitle}`, body, refetch); - }, - [ - details, - result, - hasAdvancedRequestPermission, - requestMedia, - mediaTitle, - refetch, - t, - showRequestModal, - ], - ); - const handleRequestAll = useCallback(() => { const body: MediaRequestBody = { mediaId: Number(result.id!), @@ -506,16 +367,24 @@ export const TVJellyseerrPage: React.FC = () => { showRequestModal, ]); - // Restore scroll position when navigating within seasons section - const handleSeasonsFocus = useCallback(() => { - // Use requestAnimationFrame to restore scroll after TV focus engine scrolls - requestAnimationFrame(() => { - mainScrollRef.current?.scrollTo({ - y: scrollPositionRef.current, - animated: false, - }); + const handleOpenSeasonSelectModal = useCallback(() => { + showSeasonSelectModal({ + seasons: seasons.filter((s) => s.seasonNumber !== 0), + title: mediaTitle, + mediaId: Number(result.id!), + tvdbId: details?.externalIds?.tvdbId, + hasAdvancedRequestPermission, + onRequested: refetch, }); - }, []); + }, [ + seasons, + mediaTitle, + result, + details, + hasAdvancedRequestPermission, + refetch, + showSeasonSelectModal, + ]); const handlePlay = useCallback(() => { const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId; @@ -610,7 +479,6 @@ export const TVJellyseerrPage: React.FC = () => { {/* Main content */} { paddingHorizontal: insets.left + 80, }} showsVerticalScrollIndicator={false} - scrollEventThrottle={16} - onScroll={(e) => { - scrollPositionRef.current = e.nativeEvent.contentOffset.y; - }} > {/* Top section - Poster + Content */} { {/* Request All button for TV series */} {mediaType === MediaType.TV && seasons.filter((s) => s.seasonNumber !== 0).length > 0 && - !allSeasonsAvailable && ( + hasRequestableSeasons && ( { )} - {/* Individual season cards for TV series */} + {/* Request Seasons button for TV series */} {mediaType === MediaType.TV && - orderBy( - seasons.filter((s) => s.seasonNumber !== 0), - "seasonNumber", - "desc", - ).map((season) => { - const canRequestSeason = - season.status === MediaStatus.UNKNOWN; - return ( - handleSeasonRequest(season.seasonNumber)} - canRequest={canRequestSeason} - onCardFocus={handleSeasonsFocus} - /> - ); - })} + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + hasRequestableSeasons && ( + + + + + {t("jellyseerr.request_seasons")} + + + + )} {/* Approve/Decline for managers */} diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index 104c3d184..b731ab98c 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -20,6 +20,7 @@ import { Easing, FlatList, Pressable, + ScrollView, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -400,11 +401,14 @@ export const TVActorPage: React.FC = ({ personId }) => { {/* Main content area */} - {/* Top section - Actor image + Info */} @@ -607,7 +611,7 @@ export const TVActorPage: React.FC = ({ personId }) => { )} - + ); }; diff --git a/hooks/useTVSeasonSelectModal.ts b/hooks/useTVSeasonSelectModal.ts new file mode 100644 index 000000000..7b2f4f201 --- /dev/null +++ b/hooks/useTVSeasonSelectModal.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVSeasonSelectModalState, + tvSeasonSelectModalAtom, +} from "@/utils/atoms/tvSeasonSelectModal"; +import { store } from "@/utils/store"; + +type ShowSeasonSelectModalParams = NonNullable; + +export const useTVSeasonSelectModal = () => { + const router = useRouter(); + + const showSeasonSelectModal = useCallback( + (params: ShowSeasonSelectModalParams) => { + store.set(tvSeasonSelectModalAtom, params); + router.push("/(auth)/tv-season-select-modal"); + }, + [router], + ); + + return { showSeasonSelectModal }; +}; diff --git a/translations/en.json b/translations/en.json index 9481f39d9..2a6292670 100644 --- a/translations/en.json +++ b/translations/en.json @@ -756,6 +756,10 @@ "unknown_user": "Unknown User", "select": "Select", "request_all": "Request All", + "request_seasons": "Request Seasons", + "select_seasons": "Select Seasons", + "request_selected": "Request Selected", + "n_selected": "{{count}} selected", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Seerr test failed. Please try again.", diff --git a/utils/atoms/tvSeasonSelectModal.ts b/utils/atoms/tvSeasonSelectModal.ts new file mode 100644 index 000000000..d43a13ecf --- /dev/null +++ b/utils/atoms/tvSeasonSelectModal.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; +import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; + +export type TVSeasonSelectModalState = { + seasons: Array<{ + id: number; + seasonNumber: number; + episodeCount: number; + status: MediaStatus; + }>; + title: string; + mediaId: number; + tvdbId?: number; + hasAdvancedRequestPermission: boolean; + onRequested: () => void; +} | null; + +export const tvSeasonSelectModalAtom = atom(null); From 11b6f16cd38fb9bba0191bf4347cb07589ab8737 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 078/309] fix: scale button --- components/jellyseerr/tv/TVJellyseerrPage.tsx | 1 + components/tv/TVButton.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx index d52ff85e7..d028cb4c1 100644 --- a/components/jellyseerr/tv/TVJellyseerrPage.tsx +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -653,6 +653,7 @@ export const TVJellyseerrPage: React.FC = () => { variant='secondary' hasTVPreferredFocus={!hasJellyfinMedia} refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined} + scaleAmount={1.01} > = ({ hasTVPreferredFocus = false, disabled = false, style, - scaleAmount = 1.05, + scaleAmount = 1.04, square = false, refSetter, nextFocusDown, From d8512897ad0d7162421488dd452e16de8356dc02 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 079/309] feat: seekbar left/right actions --- components/tv/TVControlButton.tsx | 11 +- components/tv/TVFocusableProgressBar.tsx | 137 ++++++++++++++++ .../video-player/controls/Controls.tv.tsx | 154 +++++++++++++----- .../controls/hooks/useRemoteControl.ts | 30 +++- 4 files changed, 286 insertions(+), 46 deletions(-) create mode 100644 components/tv/TVFocusableProgressBar.tsx diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx index 72e06ca2b..49604ba5f 100644 --- a/components/tv/TVControlButton.tsx +++ b/components/tv/TVControlButton.tsx @@ -1,6 +1,11 @@ import { Ionicons } from "@expo/vector-icons"; import type { FC } from "react"; -import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + type View, +} from "react-native"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVControlButtonProps { @@ -12,6 +17,8 @@ export interface TVControlButtonProps { hasTVPreferredFocus?: boolean; size?: number; delayLongPress?: number; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; } export const TVControlButton: FC = ({ @@ -23,12 +30,14 @@ export const TVControlButton: FC = ({ hasTVPreferredFocus, size = 32, delayLongPress = 300, + refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); return ( ; + /** Maximum value in milliseconds */ + max: SharedValue; + /** Cache progress value (SharedValue) in milliseconds */ + cacheProgress?: SharedValue; + /** Callback when the progress bar receives focus */ + onFocus?: () => void; + /** Callback when the progress bar loses focus */ + onBlur?: () => void; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component is disabled */ + disabled?: boolean; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Optional style overrides */ + style?: ViewStyle; +} + +const PROGRESS_BAR_HEIGHT = 16; + +export const TVFocusableProgressBar: React.FC = + React.memo( + ({ + progress, + max, + cacheProgress, + onFocus, + onBlur, + refSetter, + disabled = false, + hasTVPreferredFocus = false, + style, + }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + duration: 120, + onFocus, + onBlur, + }); + + const progressFillStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 ? (progress.value / max.value) * 100 : 0}%`, + })); + + const cacheProgressStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 && cacheProgress ? (cacheProgress.value / max.value) * 100 : 0}%`, + })); + + return ( + + + + {cacheProgress && ( + + )} + + + + + ); + }, + ); + +const styles = StyleSheet.create({ + pressableContainer: { + // Add padding for focus scale animation to not clip + paddingVertical: 4, + paddingHorizontal: 4, + }, + animatedContainer: { + height: PROGRESS_BAR_HEIGHT + 8, + justifyContent: "center", + borderRadius: 12, + paddingHorizontal: 4, + }, + progressTrack: { + height: PROGRESS_BAR_HEIGHT, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 8, + overflow: "hidden", + }, + cacheProgress: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "rgba(255,255,255,0.3)", + borderRadius: 8, + }, + progressFill: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "#fff", + borderRadius: 8, + }, +}); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 21e4db31e..afdb9bdb4 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -13,7 +13,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, View } from "react-native"; +import { StyleSheet, TVFocusGuideView, View } from "react-native"; import Animated, { Easing, type SharedValue, @@ -25,6 +25,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv"; +import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; @@ -128,6 +129,11 @@ export const Controls: FC = ({ type LastModalType = "audio" | "subtitle" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); + // State for progress bar focus and focus guide refs + const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); + const [playButtonRef, setPlayButtonRef] = useState(null); + const [progressBarRef, setProgressBarRef] = useState(null); + const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); @@ -249,13 +255,6 @@ export const Controls: FC = ({ // No longer needed since modals are screen-based }, []); - const { isSliding: isRemoteSliding } = useRemoteControl({ - showControls, - toggleControls, - togglePlay, - onBack: handleBack, - }); - const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); showOptions({ @@ -319,21 +318,6 @@ export const Controls: FC = ({ [], ); - const hideControls = useCallback(() => { - setShowControls(false); - }, [setShowControls]); - - const { handleControlsInteraction } = useControlsTimeout({ - showControls, - isSliding: isRemoteSliding, - episodeView: false, - onHideControls: hideControls, - timeout: TV_AUTO_HIDE_TIMEOUT, - disabled: false, - }); - - controlsInteractionRef.current = handleControlsInteraction; - const handleSeekForwardButton = useCallback(() => { const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; @@ -372,6 +356,76 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + // Progress bar D-pad seeking (10s increments for finer control) + const handleProgressSeekRight = useCallback(() => { + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + const handleProgressSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls, + toggleControls, + togglePlay, + onBack: handleBack, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onInteraction: handleRemoteInteraction, + }); + + const hideControls = useCallback(() => { + setShowControls(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); @@ -590,8 +644,8 @@ export const Controls: FC = ({ icon={isPlaying ? "pause" : "play"} onPress={handlePlayPauseButton} disabled={false} - hasTVPreferredFocus={!false && lastOpenedModal === null} size={36} + refSetter={setPlayButtonRef} /> = ({ )} - - - ({ - width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - ({ - width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - - + {/* Bidirectional focus guides - stacked together per docs */} + {/* Downward: play button → progress bar */} + {progressBarRef && ( + + )} + {/* Upward: progress bar → play button */} + {playButtonRef && ( + + )} + + {/* Progress bar with focus trapping for left/right */} + + setIsProgressBarFocused(true)} + onBlur={() => setIsProgressBarFocused(false)} + refSetter={setProgressBarRef} + hasTVPreferredFocus={lastOpenedModal === null} + /> + @@ -735,6 +797,10 @@ const styles = StyleSheet.create({ alignItems: "center", zIndex: 20, }, + focusGuide: { + height: 1, + width: "100%", + }, progressBarContainer: { height: TV_SEEKBAR_HEIGHT, justifyContent: "center", diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 406a99c3c..664d30f36 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -23,6 +23,14 @@ interface UseRemoteControlProps { disableSeeking?: boolean; /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ onBack?: () => void; + /** Whether the progress bar currently has focus */ + isProgressBarFocused?: boolean; + /** Callback for seeking left when progress bar is focused */ + onSeekLeft?: () => void; + /** Callback for seeking right when progress bar is focused */ + onSeekRight?: () => void; + /** Callback for any interaction that should reset the controls timeout */ + onInteraction?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx // These are ignored in the simplified implementation progress?: SharedValue; @@ -49,6 +57,10 @@ export function useRemoteControl({ toggleControls, togglePlay, onBack, + isProgressBarFocused, + onSeekLeft, + onSeekRight, + onInteraction, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); @@ -74,12 +86,28 @@ export function useRemoteControl({ if (togglePlay) { togglePlay(); } + onInteraction?.(); return; } - // Show controls on any D-pad press + // Handle left/right D-pad seeking when progress bar is focused + if (isProgressBarFocused) { + if (evt.eventType === "left" && onSeekLeft) { + onSeekLeft(); + return; + } + if (evt.eventType === "right" && onSeekRight) { + onSeekRight(); + return; + } + } + + // Show controls on any D-pad press, or reset timeout if already showing if (!showControls) { toggleControls(); + } else { + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); } }); From aa6b441dd1594d84db3cb1630ceed4c916a4be76 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 080/309] feat(tv): minimal seekbar --- .../video-player/controls/Controls.tv.tsx | 187 ++++++++++++++++++ .../controls/hooks/useRemoteControl.ts | 33 +++- 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index afdb9bdb4..b5ac33648 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -134,6 +134,13 @@ export const Controls: FC = ({ const [playButtonRef, setPlayButtonRef] = useState(null); const [progressBarRef, setProgressBarRef] = useState(null); + // Minimal seek bar state (shows only progress bar when seeking while controls hidden) + const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false); + const minimalSeekBarOpacity = useSharedValue(0); + const minimalSeekBarTimeoutRef = useRef | null>( + null, + ); + const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); @@ -200,6 +207,22 @@ export const Controls: FC = ({ transform: [{ translateY: bottomTranslateY.value }], })); + // Minimal seek bar animation + useEffect(() => { + const animationConfig = { + duration: 200, + easing: Easing.out(Easing.quad), + }; + minimalSeekBarOpacity.value = withTiming( + showMinimalSeekBar ? 1 : 0, + animationConfig, + ); + }, [showMinimalSeekBar, minimalSeekBarOpacity]); + + const minimalSeekBarAnimatedStyle = useAnimatedStyle(() => ({ + opacity: minimalSeekBarOpacity.value, + })); + useEffect(() => { if (item) { progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); @@ -255,6 +278,31 @@ export const Controls: FC = ({ // No longer needed since modals are screen-based }, []); + // Show minimal seek bar (only progress bar, no buttons) + const showMinimalSeek = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + + // Auto-hide after timeout + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + + // Reset minimal seek bar timeout (call on each seek action) + const _resetMinimalSeekTimeout = useCallback(() => { + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); showOptions({ @@ -395,6 +443,61 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + // Minimal seek mode handlers (only show progress bar, not full controls) + const handleMinimalSeekRight = useCallback(() => { + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + + const handleMinimalSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + min, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + // Callback for remote interactions to reset timeout const handleRemoteInteraction = useCallback(() => { controlsInteractionRef.current(); @@ -408,6 +511,8 @@ export const Controls: FC = ({ isProgressBarFocused, onSeekLeft: handleProgressSeekLeft, onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, onInteraction: handleRemoteInteraction, }); @@ -598,6 +703,63 @@ export const Controls: FC = ({ /> )} + {/* Minimal seek bar - shows only progress bar when seeking while controls hidden */} + + + {showSeekBubble && ( + + + + )} + + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + + + + + {formatTimeString(currentTime, "ms")} + + + -{formatTimeString(remainingTime, "ms")} + + + + + void; /** Callback for seeking right when progress bar is focused */ onSeekRight?: () => void; + /** Callback for seeking left when controls are hidden (minimal seek mode) */ + onMinimalSeekLeft?: () => void; + /** Callback for seeking right when controls are hidden (minimal seek mode) */ + onMinimalSeekRight?: () => void; /** Callback for any interaction that should reset the controls timeout */ onInteraction?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx @@ -60,6 +64,8 @@ export function useRemoteControl({ isProgressBarFocused, onSeekLeft, onSeekRight, + onMinimalSeekLeft, + onMinimalSeekRight, onInteraction, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component @@ -90,7 +96,23 @@ export function useRemoteControl({ return; } - // Handle left/right D-pad seeking when progress bar is focused + // Handle left/right D-pad - check controls hidden state FIRST + if (!showControls) { + // Minimal seek mode when controls are hidden + if (evt.eventType === "left" && onMinimalSeekLeft) { + onMinimalSeekLeft(); + return; + } + if (evt.eventType === "right" && onMinimalSeekRight) { + onMinimalSeekRight(); + return; + } + // For other D-pad presses, show full controls + toggleControls(); + return; + } + + // Controls are showing - handle seeking when progress bar is focused if (isProgressBarFocused) { if (evt.eventType === "left" && onSeekLeft) { onSeekLeft(); @@ -102,13 +124,8 @@ export function useRemoteControl({ } } - // Show controls on any D-pad press, or reset timeout if already showing - if (!showControls) { - toggleControls(); - } else { - // Reset the timeout on any D-pad navigation when controls are showing - onInteraction?.(); - } + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); }); return { From 096670a0c349f8a47538f2744f6961e4818bc62c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 081/309] fix(tv): better seek --- components/tv/TVFocusableProgressBar.tsx | 25 +++- .../video-player/controls/Controls.tv.tsx | 116 ++++++++++-------- 2 files changed, 86 insertions(+), 55 deletions(-) diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx index a33c072b8..42cec6b1d 100644 --- a/components/tv/TVFocusableProgressBar.tsx +++ b/components/tv/TVFocusableProgressBar.tsx @@ -78,13 +78,15 @@ export const TVFocusableProgressBar: React.FC = style={[ styles.animatedContainer, animatedStyle, - { - borderColor: focused ? "rgba(255,255,255,0.8)" : "transparent", - borderWidth: 2, - }, + focused && styles.animatedContainerFocused, ]} > - + {cacheProgress && ( = const styles = StyleSheet.create({ pressableContainer: { // Add padding for focus scale animation to not clip - paddingVertical: 4, + paddingVertical: 8, paddingHorizontal: 4, }, animatedContainer: { @@ -112,12 +114,23 @@ const styles = StyleSheet.create({ borderRadius: 12, paddingHorizontal: 4, }, + animatedContainerFocused: { + // Subtle glow effect when focused + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 12, + }, progressTrack: { height: PROGRESS_BAR_HEIGHT, backgroundColor: "rgba(255,255,255,0.2)", borderRadius: 8, overflow: "hidden", }, + progressTrackFocused: { + // Brighter track when focused + backgroundColor: "rgba(255,255,255,0.35)", + }, cacheProgress: { position: "absolute", top: 0, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b5ac33648..ee6565da7 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -200,8 +200,23 @@ export const Controls: FC = ({ controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig); bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig); + + // Hide minimal seek bar immediately when normal controls show + if (showControls) { + setShowMinimalSeekBar(false); + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + } }, [showControls, controlsOpacity, bottomTranslateY]); + // Overlay only fades, no slide + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + })); + + // Bottom controls fade and slide up const bottomAnimatedStyle = useAnimatedStyle(() => ({ opacity: controlsOpacity.value, transform: [{ translateY: bottomTranslateY.value }], @@ -689,7 +704,7 @@ export const Controls: FC = ({ return ( @@ -704,13 +719,14 @@ export const Controls: FC = ({ )} {/* Minimal seek bar - shows only progress bar when seeking while controls hidden */} + {/* Uses exact same layout as normal controls for alignment */} = ({ ]} > {showSeekBubble && ( - + = ({ )} - - - ({ - width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - ({ - width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> + {/* Same padding as TVFocusableProgressBar for alignment */} + + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + - + {formatTimeString(currentTime, "ms")} - - -{formatTimeString(remainingTime, "ms")} - + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + @@ -905,18 +931,10 @@ export const Controls: FC = ({ const styles = StyleSheet.create({ controlsContainer: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, + ...StyleSheet.absoluteFillObject, }, darkOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, + ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.4)", }, bottomContainer: { @@ -1017,21 +1035,21 @@ const styles = StyleSheet.create({ right: 0, zIndex: 5, }, - minimalSeekBarInner: { - flexDirection: "column", + minimalProgressWrapper: { + // Match TVFocusableProgressBar padding for alignment + paddingVertical: 8, + paddingHorizontal: 4, }, - minimalTrickplayContainer: { - alignItems: "center", - marginBottom: 16, + minimalProgressGlow: { + // Same glow effect and scale as focused TVFocusableProgressBar + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 12, + transform: [{ scale: 1.02 }], }, - minimalProgressContainer: { - height: TV_SEEKBAR_HEIGHT, - justifyContent: "center", - marginBottom: 8, - }, - minimalTimeContainer: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", + minimalProgressTrack: { + // Brighter track like focused state + backgroundColor: "rgba(255,255,255,0.35)", }, }); From d2790f4997a6f64f2291773451adcc467319193d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 082/309] fix(tv): seek --- .../video-player/controls/Controls.tv.tsx | 141 +++++++++++------- components/video-player/controls/constants.ts | 4 +- .../controls/hooks/useRemoteControl.ts | 48 +++++- 3 files changed, 138 insertions(+), 55 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index ee6565da7..f53611057 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -129,6 +129,9 @@ export const Controls: FC = ({ type LastModalType = "audio" | "subtitle" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); + // Track if play button should have focus (when showing controls via up/down D-pad) + const [focusPlayButton, setFocusPlayButton] = useState(false); + // State for progress bar focus and focus guide refs const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); const [playButtonRef, setPlayButtonRef] = useState(null); @@ -308,6 +311,27 @@ export const Controls: FC = ({ }, 2500); }, []); + // Show minimal seek bar without auto-hide (for continuous seeking) + const showMinimalSeekPersistent = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout - don't set a new one + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + }, []); + + // Start the minimal seek bar hide timeout + const startMinimalSeekHideTimeout = useCallback(() => { + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + // Reset minimal seek bar timeout (call on each seek action) const _resetMinimalSeekTimeout = useCallback(() => { if (minimalSeekBarTimeoutRef.current) { @@ -513,39 +537,7 @@ export const Controls: FC = ({ showMinimalSeek, ]); - // Callback for remote interactions to reset timeout - const handleRemoteInteraction = useCallback(() => { - controlsInteractionRef.current(); - }, []); - - const { isSliding: isRemoteSliding } = useRemoteControl({ - showControls, - toggleControls, - togglePlay, - onBack: handleBack, - isProgressBarFocused, - onSeekLeft: handleProgressSeekLeft, - onSeekRight: handleProgressSeekRight, - onMinimalSeekLeft: handleMinimalSeekLeft, - onMinimalSeekRight: handleMinimalSeekRight, - onInteraction: handleRemoteInteraction, - }); - - const hideControls = useCallback(() => { - setShowControls(false); - }, [setShowControls]); - - const { handleControlsInteraction } = useControlsTimeout({ - showControls, - isSliding: isRemoteSliding, - episodeView: false, - onHideControls: hideControls, - timeout: TV_AUTO_HIDE_TIMEOUT, - disabled: false, - }); - - controlsInteractionRef.current = handleControlsInteraction; - + // Continuous seeking functions (for button long-press and D-pad long-press) const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); @@ -559,7 +551,10 @@ export const Controls: FC = ({ seekBubbleTimeoutRef.current = setTimeout(() => { setShowSeekBubble(false); }, 2000); - }, []); + + // Start minimal seekbar hide timeout (if it's showing) + startMinimalSeekHideTimeout(); + }, [startMinimalSeekHideTimeout]); const startContinuousSeekForward = useCallback(() => { seekAccelerationRef.current = 1; @@ -621,6 +616,65 @@ export const Controls: FC = ({ updateSeekBubbleTime, ]); + // D-pad long press handlers - show minimal seekbar when controls are hidden + const handleDpadLongSeekForward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekForward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekForward]); + + const handleDpadLongSeekBackward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekBackward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekBackward]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + // Callback for up/down D-pad - show controls with play button focused + const handleVerticalDpad = useCallback(() => { + setFocusPlayButton(true); + setShowControls(true); + }, [setShowControls]); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls, + toggleControls, + togglePlay, + onBack: handleBack, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, + onInteraction: handleRemoteInteraction, + onLongSeekLeftStart: handleDpadLongSeekBackward, + onLongSeekRightStart: handleDpadLongSeekForward, + onLongSeekStop: stopContinuousSeeking, + onVerticalDpad: handleVerticalDpad, + }); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + const handlePlayPauseButton = useCallback(() => { togglePlay(); controlsInteractionRef.current(); @@ -820,28 +874,13 @@ export const Controls: FC = ({ disabled={false || !previousItem} size={28} /> - - = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - hasTVPreferredFocus={lastOpenedModal === null} + hasTVPreferredFocus={lastOpenedModal === null && !focusPlayButton} /> diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts index 812df3336..06f661dbd 100644 --- a/components/video-player/controls/constants.ts +++ b/components/video-player/controls/constants.ts @@ -5,8 +5,8 @@ export const CONTROLS_CONSTANTS = { TILE_WIDTH: 150, PROGRESS_UNIT_MS: 1000, // 1 second in ms PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks - LONG_PRESS_INITIAL_SEEK: 10, - LONG_PRESS_ACCELERATION: 1.1, + LONG_PRESS_INITIAL_SEEK: 30, + LONG_PRESS_ACCELERATION: 1.2, LONG_PRESS_INTERVAL: 300, SLIDER_DEBOUNCE_MS: 3, } as const; diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 4436843db..c813d1419 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -35,6 +35,14 @@ interface UseRemoteControlProps { onMinimalSeekRight?: () => void; /** Callback for any interaction that should reset the controls timeout */ onInteraction?: () => void; + /** Callback when long press seek left starts (eventKeyAction: 0) */ + onLongSeekLeftStart?: () => void; + /** Callback when long press seek right starts (eventKeyAction: 0) */ + onLongSeekRightStart?: () => void; + /** Callback when long press seek ends (eventKeyAction: 1) */ + onLongSeekStop?: () => void; + /** Callback when up/down D-pad pressed (to show controls with play button focused) */ + onVerticalDpad?: () => void; // Legacy props - kept for backwards compatibility with mobile Controls.tsx // These are ignored in the simplified implementation progress?: SharedValue; @@ -67,6 +75,10 @@ export function useRemoteControl({ onMinimalSeekLeft, onMinimalSeekRight, onInteraction, + onLongSeekLeftStart, + onLongSeekRightStart, + onLongSeekStop, + onVerticalDpad, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); @@ -96,9 +108,33 @@ export function useRemoteControl({ return; } - // Handle left/right D-pad - check controls hidden state FIRST + // Handle long press D-pad for continuous seeking (works in both modes) + // Must be checked BEFORE the showControls check to work when controls are hidden + if (evt.eventType === "longLeft") { + if (evt.eventKeyAction === 0 && onLongSeekLeftStart) { + // Key pressed - start continuous seeking backward + onLongSeekLeftStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + if (evt.eventType === "longRight") { + if (evt.eventKeyAction === 0 && onLongSeekRightStart) { + // Key pressed - start continuous seeking forward + onLongSeekRightStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + // Handle D-pad when controls are hidden if (!showControls) { - // Minimal seek mode when controls are hidden + // Minimal seek mode for left/right if (evt.eventType === "left" && onMinimalSeekLeft) { onMinimalSeekLeft(); return; @@ -107,6 +143,14 @@ export function useRemoteControl({ onMinimalSeekRight(); return; } + // Up/down shows controls with play button focused + if ( + (evt.eventType === "up" || evt.eventType === "down") && + onVerticalDpad + ) { + onVerticalDpad(); + return; + } // For other D-pad presses, show full controls toggleControls(); return; From 4b7007386f652678d37f762b3373255b5a8b3879 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH 083/309] fix(tv): font size --- components/tv/TVFocusableProgressBar.tsx | 2 +- components/video-player/controls/Controls.tv.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx index 42cec6b1d..e33e44445 100644 --- a/components/tv/TVFocusableProgressBar.tsx +++ b/components/tv/TVFocusableProgressBar.tsx @@ -33,7 +33,7 @@ export interface TVFocusableProgressBarProps { style?: ViewStyle; } -const PROGRESS_BAR_HEIGHT = 16; +const PROGRESS_BAR_HEIGHT = 14; export const TVFocusableProgressBar: React.FC = React.memo( diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index f53611057..c885ee889 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -26,6 +26,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv"; import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; @@ -70,7 +71,7 @@ interface Props { addSubtitleFile?: (path: string) => void; } -const TV_SEEKBAR_HEIGHT = 16; +const TV_SEEKBAR_HEIGHT = 14; const TV_AUTO_HIDE_TIMEOUT = 5000; export const Controls: FC = ({ @@ -991,11 +992,11 @@ const styles = StyleSheet.create({ }, subtitleText: { color: "rgba(255,255,255,0.6)", - fontSize: 18, + fontSize: TVTypography.body, }, titleText: { color: "#fff", - fontSize: 28, + fontSize: TVTypography.heading, fontWeight: "bold", }, controlButtonsRow: { @@ -1055,7 +1056,7 @@ const styles = StyleSheet.create({ }, timeText: { color: "rgba(255,255,255,0.7)", - fontSize: 22, + fontSize: TVTypography.body, }, timeRight: { flexDirection: "column", @@ -1063,7 +1064,7 @@ const styles = StyleSheet.create({ }, endsAtText: { color: "rgba(255,255,255,0.5)", - fontSize: 16, + fontSize: TVTypography.callout, marginTop: 2, }, // Minimal seek bar styles From 3f882ecade90a29ce673589e32fe3aba6ce74d6b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:10:18 +0100 Subject: [PATCH 084/309] feat(tv): add technical info overlay to player controls --- app/(auth)/player/direct-player.tsx | 5 ++ .../video-player/controls/Controls.tv.tsx | 40 ++++++++- .../controls/TechnicalInfoOverlay.tsx | 86 +++++++++++-------- 3 files changed, 95 insertions(+), 36 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 56ff07547..13c88a924 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -1175,6 +1175,11 @@ export default function page() { goToNextItem={goToNextItem} onRefreshSubtitleTracks={handleRefreshSubtitleTracks} addSubtitleFile={addSubtitleFile} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={handleToggleTechnicalInfo} + getTechnicalInfo={getTechnicalInfo} + playMethod={playMethod} + transcodeReasons={transcodeReasons} /> ) : ( ; addSubtitleFile?: (path: string) => void; + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; + getTechnicalInfo?: () => Promise; + playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; + transcodeReasons?: string[]; } const TV_SEEKBAR_HEIGHT = 14; @@ -97,6 +104,11 @@ export const Controls: FC = ({ goToNextItem: goToNextItemProp, onRefreshSubtitleTracks, addSubtitleFile, + showTechnicalInfo, + onToggleTechnicalInfo, + getTechnicalInfo, + playMethod, + transcodeReasons, }) => { const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -127,7 +139,7 @@ export const Controls: FC = ({ const { showSubtitleModal } = useTVSubtitleModal(); // Track which button should have preferred focus when controls show - type LastModalType = "audio" | "subtitle" | null; + type LastModalType = "audio" | "subtitle" | "techInfo" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); // Track if play button should have focus (when showing controls via up/down D-pad) @@ -383,6 +395,12 @@ export const Controls: FC = ({ onRefreshSubtitleTracks, ]); + const handleToggleTechnicalInfo = useCallback(() => { + setLastOpenedModal("techInfo"); + onToggleTechnicalInfo?.(); + controlsInteractionRef.current(); + }, [onToggleTechnicalInfo]); + const effectiveProgress = useSharedValue(0); const SEEK_THRESHOLD_MS = 5000; @@ -763,6 +781,16 @@ export const Controls: FC = ({ pointerEvents='none' /> + {getTechnicalInfo && ( + + )} + {nextItem && ( = ({ hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"} size={24} /> + + {getTechnicalInfo && ( + + )} {showSeekBubble && ( diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index 1498990d1..fbd357f69 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -7,6 +7,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { TVTypography } from "@/constants/TVTypography"; import type { TechnicalInfo } from "@/modules/mpv-player"; import { useSettings } from "@/utils/atoms/settings"; import { HEADER_LAYOUT } from "./constants"; @@ -121,7 +122,7 @@ const formatTranscodeReason = (reason: string): string => { export const TechnicalInfoOverlay: FC = memo( ({ - showControls, + showControls: _showControls, visible, getTechnicalInfo, playMethod, @@ -168,64 +169,64 @@ export const TechnicalInfoOverlay: FC = memo( opacity: opacity.value, })); - // Hide on TV platforms - if (Platform.isTV) return null; - // Don't render if not visible if (!visible) return null; + // TV-specific styles + const containerStyle = Platform.isTV + ? { + top: Math.max(insets.top, 48) + 20, + left: Math.max(insets.left, 48) + 20, + } + : { + top: + (settings?.safeAreaInControlsEnabled ?? true) + ? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4 + : HEADER_LAYOUT.CONTAINER_PADDING + 4, + left: + (settings?.safeAreaInControlsEnabled ?? true) + ? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20 + : HEADER_LAYOUT.CONTAINER_PADDING + 20, + }; + + const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText; + const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText; + const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox; + return ( - + {playMethod && ( {getPlayMethodLabel(playMethod)} )} {transcodeReasons && transcodeReasons.length > 0 && ( - + {transcodeReasons.map(formatTranscodeReason).join(", ")} )} {info?.videoWidth && info?.videoHeight && ( - + {info.videoWidth}x{info.videoHeight} )} {info?.videoCodec && ( - + Video: {formatCodec(info.videoCodec)} {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} )} {info?.audioCodec && ( - - Audio: {formatCodec(info.audioCodec)} - + Audio: {formatCodec(info.audioCodec)} )} {(info?.videoBitrate || info?.audioBitrate) && ( - + Bitrate:{" "} {info.videoBitrate ? formatBitrate(info.videoBitrate) @@ -235,18 +236,16 @@ export const TechnicalInfoOverlay: FC = memo( )} {info?.cacheSeconds !== undefined && ( - + Buffer: {info.cacheSeconds.toFixed(1)}s )} {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( - + Dropped: {info.droppedFrames} frames )} - {!info && !playMethod && ( - Loading... - )} + {!info && !playMethod && Loading...} ); @@ -267,12 +266,25 @@ const styles = StyleSheet.create({ paddingVertical: 8, minWidth: 150, }, + infoBoxTV: { + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 12, + paddingHorizontal: 20, + paddingVertical: 16, + minWidth: 250, + }, infoText: { color: "white", fontSize: 12, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", lineHeight: 18, }, + infoTextTV: { + color: "white", + fontSize: TVTypography.body, + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + lineHeight: TVTypography.body * 1.5, + }, warningText: { color: "#ff9800", }, @@ -280,4 +292,8 @@ const styles = StyleSheet.create({ color: "#fbbf24", fontSize: 10, }, + reasonTextTV: { + color: "#fbbf24", + fontSize: TVTypography.callout, + }, }); From be92b5d75e92f28174e9f76d3e938101997ca7be Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:15:02 +0100 Subject: [PATCH 085/309] feat(player): enhance technical info overlay with codec details --- components/video-player/controls/Controls.tsx | 1 + .../video-player/controls/Controls.tv.tsx | 3 + .../controls/TechnicalInfoOverlay.tsx | 106 +++++++++++++++++- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 96dfad6b3..a336143e9 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -481,6 +481,7 @@ export const Controls: FC = ({ getTechnicalInfo={getTechnicalInfo} playMethod={playMethod} transcodeReasons={transcodeReasons} + mediaSource={mediaSource} /> )} = ({ getTechnicalInfo={getTechnicalInfo} playMethod={playMethod} transcodeReasons={transcodeReasons} + mediaSource={mediaSource} + currentAudioIndex={audioIndex} + currentSubtitleIndex={subtitleIndex} /> )} diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index fbd357f69..5d87e6970 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -1,4 +1,12 @@ -import { type FC, memo, useCallback, useEffect, useState } from "react"; +import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; +import { + type FC, + memo, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { Platform, StyleSheet, Text, View } from "react-native"; import Animated, { Easing, @@ -20,6 +28,9 @@ interface TechnicalInfoOverlayProps { getTechnicalInfo: () => Promise; playMethod?: PlayMethod; transcodeReasons?: string[]; + mediaSource?: MediaSourceInfo | null; + currentSubtitleIndex?: number; + currentAudioIndex?: number; } const formatBitrate = (bitsPerSecond: number): string => { @@ -48,10 +59,51 @@ const formatCodec = (codec: string): string => { flac: "FLAC", opus: "Opus", mp3: "MP3", + // Subtitle codecs + srt: "SRT", + subrip: "SRT", + ass: "ASS", + ssa: "SSA", + webvtt: "WebVTT", + vtt: "WebVTT", + pgs: "PGS", + hdmv_pgs_subtitle: "PGS", + dvd_subtitle: "VobSub", + dvdsub: "VobSub", + mov_text: "MOV Text", + cc_dec: "CC", + eia_608: "CC", }; return codecMap[codec.toLowerCase()] || codec.toUpperCase(); }; +const formatAudioChannels = (channels: number): string => { + switch (channels) { + case 1: + return "Mono"; + case 2: + return "Stereo"; + case 6: + return "5.1"; + case 8: + return "7.1"; + default: + return `${channels}ch`; + } +}; + +const formatVideoRange = (range?: string | null): string | null => { + if (!range || range === "SDR") return null; + const rangeMap: Record = { + HDR10: "HDR10", + HDR10Plus: "HDR10+", + HLG: "HLG", + "Dolby Vision": "Dolby Vision", + DolbyVision: "Dolby Vision", + }; + return rangeMap[range] || range; +}; + const formatFps = (fps: number): string => { // Common frame rates if (Math.abs(fps - 23.976) < 0.01) return "23.976"; @@ -127,6 +179,9 @@ export const TechnicalInfoOverlay: FC = memo( getTechnicalInfo, playMethod, transcodeReasons, + mediaSource, + currentSubtitleIndex, + currentAudioIndex, }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -134,6 +189,39 @@ export const TechnicalInfoOverlay: FC = memo( const opacity = useSharedValue(0); + // Extract stream info from media source + const streamInfo = useMemo(() => { + if (!mediaSource?.MediaStreams) return null; + + const videoStream = mediaSource.MediaStreams.find( + (s) => s.Type === "Video", + ); + const audioStream = mediaSource.MediaStreams.find( + (s) => + s.Type === "Audio" && + (currentAudioIndex !== undefined + ? s.Index === currentAudioIndex + : s.IsDefault), + ); + const subtitleStream = mediaSource.MediaStreams.find( + (s) => + s.Type === "Subtitle" && + currentSubtitleIndex !== undefined && + currentSubtitleIndex >= 0 && + s.Index === currentSubtitleIndex, + ); + + return { + container: mediaSource.Container, + videoRange: videoStream?.VideoRangeType, + bitDepth: videoStream?.BitDepth, + audioChannels: audioStream?.Channels, + audioCodecFromSource: audioStream?.Codec, + subtitleCodec: subtitleStream?.Codec, + subtitleTitle: subtitleStream?.DisplayTitle, + }; + }, [mediaSource, currentAudioIndex, currentSubtitleIndex]); + // Animate visibility based on visible prop only (stays visible regardless of controls) useEffect(() => { opacity.value = withTiming(visible ? 1 : 0, { @@ -214,6 +302,10 @@ export const TechnicalInfoOverlay: FC = memo( {info?.videoWidth && info?.videoHeight && ( {info.videoWidth}x{info.videoHeight} + {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""} + {formatVideoRange(streamInfo?.videoRange) + ? ` ${formatVideoRange(streamInfo?.videoRange)}` + : ""} )} {info?.videoCodec && ( @@ -223,7 +315,17 @@ export const TechnicalInfoOverlay: FC = memo( )} {info?.audioCodec && ( - Audio: {formatCodec(info.audioCodec)} + + Audio: {formatCodec(info.audioCodec)} + {streamInfo?.audioChannels + ? ` ${formatAudioChannels(streamInfo.audioChannels)}` + : ""} + + )} + {streamInfo?.subtitleCodec && ( + + Subtitle: {formatCodec(streamInfo.subtitleCodec)} + )} {(info?.videoBitrate || info?.audioBitrate) && ( From be2fd53f312f97d9f28232032bccc6636f2ba25a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:29:57 +0100 Subject: [PATCH 086/309] fix(tv): resolve subtitle selector index mismatch using VideoContext tracks --- app/(auth)/tv-subtitle-modal.tsx | 26 +++---- components/ItemContent.tv.tsx | 70 ++++++++++++++----- .../video-player/controls/Controls.tv.tsx | 27 ++++--- hooks/useTVSubtitleModal.ts | 14 ++-- utils/atoms/tvSubtitleModal.ts | 12 ++-- 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 8bc0cc075..7167f7ba5 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -21,6 +21,7 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import { TVTabButton, useTVFocusAnimation } from "@/components/tv"; +import type { Track } from "@/components/video-player/controls/types"; import useRouter from "@/hooks/useAppRouter"; import { type SubtitleSearchResult, @@ -544,7 +545,7 @@ export default function TVSubtitleModal() { const initialSelectedTrackIndex = useMemo(() => { if (currentSubtitleIndex === -1) return 0; const trackIdx = subtitleTracks.findIndex( - (t) => t.Index === currentSubtitleIndex, + (t) => t.index === currentSubtitleIndex, ); return trackIdx >= 0 ? trackIdx + 1 : 0; }, [subtitleTracks, currentSubtitleIndex]); @@ -612,11 +613,11 @@ export default function TVSubtitleModal() { ); const handleTrackSelect = useCallback( - (index: number) => { - modalState?.onSubtitleIndexChange(index); + (option: { setTrack?: () => void }) => { + option.setTrack?.(); handleClose(); }, - [modalState, handleClose], + [handleClose], ); const handleDownload = useCallback( @@ -683,16 +684,17 @@ export default function TVSubtitleModal() { sublabel: undefined as string | undefined, value: -1, selected: currentSubtitleIndex === -1, + setTrack: () => modalState?.onDisableSubtitles?.(), }; - const options = subtitleTracks.map((track) => ({ - label: - track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, - sublabel: track.Codec?.toUpperCase(), - value: track.Index!, - selected: track.Index === currentSubtitleIndex, + const options = subtitleTracks.map((track: Track) => ({ + label: track.name, + sublabel: undefined as string | undefined, + value: track.index, + selected: track.index === currentSubtitleIndex, + setTrack: track.setTrack, })); return [noneOption, ...options]; - }, [subtitleTracks, currentSubtitleIndex, t]); + }, [subtitleTracks, currentSubtitleIndex, t, modalState]); if (!modalState) { return null; @@ -762,7 +764,7 @@ export default function TVSubtitleModal() { sublabel={option.sublabel} selected={option.selected} hasTVPreferredFocus={index === initialSelectedTrackIndex} - onPress={() => handleTrackSelect(option.value)} + onPress={() => handleTrackSelect(option)} /> ))} diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index ac4fad327..999d6037b 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -9,7 +9,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -30,6 +36,7 @@ import { TVSeriesNavigation, TVTechnicalDetails, } from "@/components/tv"; +import type { Track } from "@/components/video-player/controls/types"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; @@ -145,14 +152,32 @@ export const ItemContentTV: React.FC = React.memo( return streams ?? []; }, [selectedOptions?.mediaSource]); - // Get available subtitle tracks - const subtitleTracks = useMemo(() => { + // Get available subtitle tracks (raw MediaStream[] for label lookup) + const subtitleStreams = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( (s) => s.Type === "Subtitle", ); return streams ?? []; }, [selectedOptions?.mediaSource]); + // Store handleSubtitleChange in a ref for stable callback reference + const handleSubtitleChangeRef = useRef<((index: number) => void) | null>( + null, + ); + + // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) + const subtitleTracksForModal = useMemo((): Track[] => { + return subtitleStreams.map((stream) => ({ + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index: stream.Index ?? -1, + setTrack: () => { + handleSubtitleChangeRef.current?.(stream.Index ?? -1); + }, + })); + }, [subtitleStreams]); + // Get available media sources const mediaSources = useMemo(() => { return (itemWithSources ?? item)?.MediaSources ?? []; @@ -207,6 +232,9 @@ export const ItemContentTV: React.FC = React.memo( ); }, []); + // Keep the ref updated with the latest callback + handleSubtitleChangeRef.current = handleSubtitleChange; + const handleMediaSourceChange = useCallback( (mediaSource: MediaSourceInfo) => { const defaultAudio = mediaSource.MediaStreams?.find( @@ -241,9 +269,7 @@ export const ItemContentTV: React.FC = React.memo( }, [item?.Id, queryClient]); // Refresh subtitle tracks by fetching fresh item data from Jellyfin - const refreshSubtitleTracks = useCallback(async (): Promise< - MediaStream[] - > => { + const refreshSubtitleTracks = useCallback(async (): Promise => { if (!api || !item?.Id) return []; try { @@ -262,12 +288,22 @@ export const ItemContentTV: React.FC = React.memo( ) : freshItem.MediaSources?.[0]; - // Return subtitle tracks from the fresh data - return ( + // Get subtitle streams from the fresh data + const streams = mediaSource?.MediaStreams?.filter( (s: MediaStream) => s.Type === "Subtitle", - ) ?? [] - ); + ) ?? []; + + // Convert to Track[] with setTrack callbacks + return streams.map((stream) => ({ + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index: stream.Index ?? -1, + setTrack: () => { + handleSubtitleChangeRef.current?.(stream.Index ?? -1); + }, + })); } catch (error) { console.error("Failed to refresh subtitle tracks:", error); return []; @@ -285,13 +321,13 @@ export const ItemContentTV: React.FC = React.memo( const selectedSubtitleLabel = useMemo(() => { if (selectedOptions?.subtitleIndex === -1) return t("item_card.subtitles.none"); - const track = subtitleTracks.find( + const track = subtitleStreams.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); return ( track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") ); - }, [subtitleTracks, selectedOptions?.subtitleIndex, t]); + }, [subtitleStreams, selectedOptions?.subtitleIndex, t]); const selectedMediaSourceLabel = useMemo(() => { const source = selectedOptions?.mediaSource; @@ -353,7 +389,7 @@ export const ItemContentTV: React.FC = React.memo( // Determine which option button is the last one (for focus guide targeting) const lastOptionButton = useMemo(() => { const hasSubtitleOption = - subtitleTracks.length > 0 || + subtitleStreams.length > 0 || selectedOptions?.subtitleIndex !== undefined; const hasAudioOption = audioTracks.length > 0; const hasMediaSourceOption = mediaSources.length > 1; @@ -363,7 +399,7 @@ export const ItemContentTV: React.FC = React.memo( if (hasMediaSourceOption) return "mediaSource"; return "quality"; }, [ - subtitleTracks.length, + subtitleStreams.length, selectedOptions?.subtitleIndex, audioTracks.length, mediaSources.length, @@ -651,7 +687,7 @@ export const ItemContentTV: React.FC = React.memo( )} {/* Subtitle selector */} - {(subtitleTracks.length > 0 || + {(subtitleStreams.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( = React.memo( showSubtitleModal({ item, mediaSourceId: selectedOptions?.mediaSource?.Id, - subtitleTracks, + subtitleTracks: subtitleTracksForModal, currentSubtitleIndex: selectedOptions?.subtitleIndex ?? -1, - onSubtitleIndexChange: handleSubtitleChange, + onDisableSubtitles: () => handleSubtitleChange(-1), onServerSubtitleDownloaded: handleServerSubtitleDownloaded, refreshSubtitleTracks, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 6415b0770..01f4ad817 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -39,6 +39,7 @@ import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; import { CONTROLS_CONSTANTS } from "./constants"; +import { useVideoContext } from "./contexts/VideoContext"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoTime } from "./hooks/useVideoTime"; import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; @@ -138,6 +139,9 @@ export const Controls: FC = ({ // TV Subtitle Modal hook const { showSubtitleModal } = useTVSubtitleModal(); + // Get subtitle tracks from VideoContext (with proper MPV index mapping) + const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext(); + // Track which button should have preferred focus when controls show type LastModalType = "audio" | "subtitle" | "techInfo" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); @@ -161,7 +165,7 @@ export const Controls: FC = ({ return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); - const subtitleTracks = useMemo(() => { + const _subtitleTracks = useMemo(() => { return ( mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [] ); @@ -183,7 +187,7 @@ export const Controls: FC = ({ [onAudioIndexChange], ); - const handleSubtitleChange = useCallback( + const _handleSubtitleChange = useCallback( (index: number) => { onSubtitleIndexChange?.(index); }, @@ -374,25 +378,32 @@ export const Controls: FC = ({ 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, + ); showSubtitleModal({ item, mediaSourceId: mediaSource?.Id, - subtitleTracks, + subtitleTracks: tracksWithoutDisable, currentSubtitleIndex: subtitleIndex ?? -1, - onSubtitleIndexChange: handleSubtitleChange, + onDisableSubtitles: () => { + // Find and call the "Disable" track's setTrack from VideoContext + const disableTrack = videoContextSubtitleTracks?.find( + (t) => t.index === -1, + ); + disableTrack?.setTrack(); + }, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, - refreshSubtitleTracks: onRefreshSubtitleTracks, }); controlsInteractionRef.current(); }, [ showSubtitleModal, item, mediaSource?.Id, - subtitleTracks, + videoContextSubtitleTracks, subtitleIndex, - handleSubtitleChange, handleLocalSubtitleDownloaded, - onRefreshSubtitleTracks, ]); const handleToggleTechnicalInfo = useCallback(() => { diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts index 1e9df9279..38d442239 100644 --- a/hooks/useTVSubtitleModal.ts +++ b/hooks/useTVSubtitleModal.ts @@ -1,8 +1,6 @@ -import type { - BaseItemDto, - MediaStream, -} from "@jellyfin/sdk/lib/generated-client"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useCallback } from "react"; +import type { Track } from "@/components/video-player/controls/types"; import useRouter from "@/hooks/useAppRouter"; import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; import { store } from "@/utils/store"; @@ -10,12 +8,12 @@ import { store } from "@/utils/store"; interface ShowSubtitleModalParams { item: BaseItemDto; mediaSourceId?: string | null; - subtitleTracks: MediaStream[]; + subtitleTracks: Track[]; currentSubtitleIndex: number; - onSubtitleIndexChange: (index: number) => void; + onDisableSubtitles?: () => void; onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; - refreshSubtitleTracks?: () => Promise; + refreshSubtitleTracks?: () => Promise; } export const useTVSubtitleModal = () => { @@ -28,7 +26,7 @@ export const useTVSubtitleModal = () => { mediaSourceId: params.mediaSourceId, subtitleTracks: params.subtitleTracks, currentSubtitleIndex: params.currentSubtitleIndex, - onSubtitleIndexChange: params.onSubtitleIndexChange, + onDisableSubtitles: params.onDisableSubtitles, onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, refreshSubtitleTracks: params.refreshSubtitleTracks, diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts index 1fbb900a5..3a940c12f 100644 --- a/utils/atoms/tvSubtitleModal.ts +++ b/utils/atoms/tvSubtitleModal.ts @@ -1,18 +1,16 @@ -import type { - BaseItemDto, - MediaStream, -} from "@jellyfin/sdk/lib/generated-client"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { atom } from "jotai"; +import type { Track } from "@/components/video-player/controls/types"; export type TVSubtitleModalState = { item: BaseItemDto; mediaSourceId?: string | null; - subtitleTracks: MediaStream[]; + subtitleTracks: Track[]; currentSubtitleIndex: number; - onSubtitleIndexChange: (index: number) => void; + onDisableSubtitles?: () => void; onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; - refreshSubtitleTracks?: () => Promise; + refreshSubtitleTracks?: () => Promise; } | null; export const tvSubtitleModalAtom = atom(null); From 02a65059b94a63cdadbadbae9fdeb7e85f71e5bd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:37:14 +0100 Subject: [PATCH 087/309] fix(tv): set tv env --- app.json | 1 + plugins/withTVXcodeEnv.js | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 plugins/withTVXcodeEnv.js diff --git a/app.json b/app.json index 0c7690ee6..668c6163a 100644 --- a/app.json +++ b/app.json @@ -139,6 +139,7 @@ ["./plugins/withTrustLocalCerts.js"], ["./plugins/withGradleProperties.js"], ["./plugins/withTVOSAppIcon.js"], + ["./plugins/withTVXcodeEnv.js"], [ "./plugins/withGitPod.js", { diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.js new file mode 100644 index 000000000..ccdbc8424 --- /dev/null +++ b/plugins/withTVXcodeEnv.js @@ -0,0 +1,47 @@ +const { withDangerousMod } = require("@expo/config-plugins"); +const fs = require("node:fs"); +const path = require("node:path"); + +/** + * Expo config plugin that adds EXPO_TV=1 to .xcode.env.local for TV builds. + * + * This ensures that when building directly from Xcode (without using `bun run ios:tv`), + * Metro bundler knows it's a TV build and properly excludes unsupported modules + * like react-native-track-player. + */ +const withTVXcodeEnv = (config) => { + // Only apply for TV builds + if (process.env.EXPO_TV !== "1") { + return config; + } + + return withDangerousMod(config, [ + "ios", + async (config) => { + const iosPath = path.join(config.modRequest.projectRoot, "ios"); + const xcodeEnvLocalPath = path.join(iosPath, ".xcode.env.local"); + + // Read existing content or start fresh + let content = ""; + if (fs.existsSync(xcodeEnvLocalPath)) { + content = fs.readFileSync(xcodeEnvLocalPath, "utf-8"); + } + + // Add EXPO_TV=1 if not already present + const expoTvExport = "export EXPO_TV=1"; + if (!content.includes(expoTvExport)) { + // Ensure we have a newline at the end before adding + if (content.length > 0 && !content.endsWith("\n")) { + content += "\n"; + } + content += `${expoTvExport}\n`; + fs.writeFileSync(xcodeEnvLocalPath, content); + console.log("[withTVXcodeEnv] Added EXPO_TV=1 to .xcode.env.local"); + } + + return config; + }, + ]); +}; + +module.exports = withTVXcodeEnv; From 26e848938423ae30f9cc6d27ea1114bf19986f80 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:37:35 +0100 Subject: [PATCH 088/309] fix(tv): season modal using correct modal --- app/(auth)/tv-series-season-modal.tsx | 188 ++++++++++++++++++++++++++ components/series/TVSeriesPage.tsx | 50 ++++--- hooks/useTVSeriesSeasonModal.ts | 34 +++++ utils/atoms/tvSeriesSeasonModal.ts | 14 ++ 4 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 app/(auth)/tv-series-season-modal.tsx create mode 100644 hooks/useTVSeriesSeasonModal.ts create mode 100644 utils/atoms/tvSeriesSeasonModal.ts diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx new file mode 100644 index 000000000..05b9ca8c5 --- /dev/null +++ b/app/(auth)/tv-series-season-modal.tsx @@ -0,0 +1,188 @@ +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVCancelButton, TVOptionCard } from "@/components/tv"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +export default function TVSeriesSeasonModalPage() { + const router = useRouter(); + const modalState = useAtomValue(tvSeriesSeasonModalAtom); + const { t } = useTranslation(); + + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + const initialSelectedIndex = useMemo(() => { + if (!modalState?.seasons) return 0; + const idx = modalState.seasons.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [modalState?.seasons]); + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvSeriesSeasonModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + // Focus on the selected card when ready + useEffect(() => { + if (isReady && firstCardRef.current) { + const timer = setTimeout(() => { + (firstCardRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady]); + + const handleSelect = (seasonIndex: number) => { + if (modalState?.onSeasonSelect) { + modalState.onSeasonSelect(seasonIndex); + } + router.back(); + }; + + const handleCancel = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + {t("item_card.select_season")} + + {isReady && ( + + {modalState.seasons.map((season, index) => ( + handleSelect(season.value)} + width={180} + height={85} + /> + ))} + + )} + + {isReady && ( + + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: TVTypography.callout, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 20, + gap: 12, + }, + cancelButtonContainer: { + marginTop: 16, + paddingHorizontal: 48, + alignItems: "flex-start", + }, +}); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index e11a06843..69c8148d2 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -4,7 +4,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { LinearGradient } from "expo-linear-gradient"; import { useSegments } from "expo-router"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import React, { useCallback, useEffect, @@ -29,12 +29,13 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { seasonIndexAtom } from "@/components/series/SeasonPicker"; import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; -import { TVOptionSelector } from "@/components/tv/TVOptionSelector"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; import { buildOfflineSeasons, getDownloadedEpisodesForSeason, @@ -221,6 +222,9 @@ export const TVSeriesPage: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { getDownloadedItems, downloadedItems } = useDownload(); + const { showSeasonModal } = useTVSeriesSeasonModal(); + const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); + const isSeasonModalVisible = seasonModalState !== null; // Season state const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); @@ -229,9 +233,6 @@ export const TVSeriesPage: React.FC = ({ [item.Id, seasonIndexState], ); - // Season selector modal state - const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); - // Focus guide refs (using useState to trigger re-renders when refs are set) const [playButtonRef, setPlayButtonRef] = useState(null); const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); @@ -405,16 +406,6 @@ export const TVSeriesPage: React.FC = ({ [item.Id, setSeasonIndexState], ); - // Open season modal - const handleOpenSeasonModal = useCallback(() => { - setIsSeasonModalVisible(true); - }, []); - - // Close season modal - const handleCloseSeasonModal = useCallback(() => { - setIsSeasonModalVisible(false); - }, []); - // Season options for the modal const seasonOptions = useMemo(() => { return seasons.map((season: BaseItemDto) => ({ @@ -426,6 +417,23 @@ export const TVSeriesPage: React.FC = ({ })); }, [seasons, selectedSeasonIndex]); + // Open season modal + const handleOpenSeasonModal = useCallback(() => { + if (!item.Id) return; + showSeasonModal({ + seasons: seasonOptions, + selectedSeasonIndex, + itemId: item.Id, + onSeasonSelect: handleSeasonSelect, + }); + }, [ + item.Id, + seasonOptions, + selectedSeasonIndex, + handleSeasonSelect, + showSeasonModal, + ]); + // Get play button text const playButtonText = useMemo(() => { if (!nextUnwatchedEpisode) return t("common.play"); @@ -646,18 +654,6 @@ export const TVSeriesPage: React.FC = ({ - - {/* Season selector modal */} - ); }; diff --git a/hooks/useTVSeriesSeasonModal.ts b/hooks/useTVSeriesSeasonModal.ts new file mode 100644 index 000000000..dcd5d4784 --- /dev/null +++ b/hooks/useTVSeriesSeasonModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +interface ShowSeasonModalParams { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} + +export const useTVSeriesSeasonModal = () => { + const router = useRouter(); + + const showSeasonModal = useCallback( + (params: ShowSeasonModalParams) => { + store.set(tvSeriesSeasonModalAtom, { + seasons: params.seasons, + selectedSeasonIndex: params.selectedSeasonIndex, + itemId: params.itemId, + onSeasonSelect: params.onSeasonSelect, + }); + router.push("/(auth)/tv-series-season-modal"); + }, + [router], + ); + + return { showSeasonModal }; +}; diff --git a/utils/atoms/tvSeriesSeasonModal.ts b/utils/atoms/tvSeriesSeasonModal.ts new file mode 100644 index 000000000..99e92193e --- /dev/null +++ b/utils/atoms/tvSeriesSeasonModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; + +export type TVSeriesSeasonModalState = { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} | null; + +export const tvSeriesSeasonModalAtom = atom(null); From 093fcc6187ea98c276605c53fad839976159322a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:37:54 +0100 Subject: [PATCH 089/309] fix(tv): season modal --- app/_layout.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/_layout.tsx b/app/_layout.tsx index 9a377803a..4599bcc48 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -461,6 +461,14 @@ function Layout() { animation: "fade", }} /> + Date: Thu, 22 Jan 2026 09:08:51 +0100 Subject: [PATCH 090/309] fix(tv): resolve mpv player exit freeze by async mpv cleanup --- .claude/learned-facts.md | 8 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 75 +++++++++++++++---- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 0dba9d7eb..67a2243c6 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -24,4 +24,10 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ -- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ \ No newline at end of file +- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ + +- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_ + +- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_ + +- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_ \ No newline at end of file diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 35b112eb5..af55826f3 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -30,8 +30,11 @@ final class MPVLayerRenderer { } private let displayLayer: AVSampleBufferDisplayLayer - private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + private let queue: DispatchQueue private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent) + + // Key to identify if we're on the mpv queue (to avoid deadlock in stop()) + private static let queueKey = DispatchSpecificKey() private var mpv: OpaquePointer? @@ -42,8 +45,18 @@ final class MPVLayerRenderer { private var initialSubtitleId: Int? private var initialAudioId: Int? - private var isRunning = false - private var isStopping = false + private var _isRunning = false + private var _isStopping = false + + private var isRunning: Bool { + get { stateQueue.sync { _isRunning } } + set { stateQueue.async(flags: .barrier) { self._isRunning = newValue } } + } + + private var isStopping: Bool { + get { stateQueue.sync { _isStopping } } + set { stateQueue.sync(flags: .barrier) { _isStopping = newValue } } // Must be sync for stop() to work + } // KVO observation for display layer status private var statusObservation: NSKeyValueObservation? @@ -116,6 +129,8 @@ final class MPVLayerRenderer { init(displayLayer: AVSampleBufferDisplayLayer) { self.displayLayer = displayLayer + self.queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + queue.setSpecific(key: Self.queueKey, value: true) observeDisplayLayerStatus() } @@ -176,10 +191,16 @@ final class MPVLayerRenderer { // Use AVFoundation video output - required for PiP support checkError(mpv_set_option_string(handle, "vo", "avfoundation")) - // Enable composite OSD mode - renders subtitles directly onto video frames using GPU - // This is better for PiP as subtitles are baked into the video - // NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit + // Composite OSD mode - renders subtitles directly onto video frames using GPU + // CRITICAL: This option MUST be set immediately after vo=avfoundation, before hwdec options. + // On tvOS, moving this elsewhere causes the app to freeze when exiting the player. + // - iOS: "yes" for PiP subtitle support (subtitles baked into video) + // - tvOS: "no" to prevent gray tint + frame drops with subtitles + #if os(tvOS) + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no")) + #else checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) + #endif // Hardware decoding with VideoToolbox // On simulator, use software decoding since VideoToolbox is not available @@ -225,19 +246,41 @@ final class MPVLayerRenderer { if !isRunning, mpv == nil { return } isRunning = false isStopping = true - + // Stop observing display layer status statusObservation?.invalidate() statusObservation = nil - - queue.sync { [weak self] in - guard let self, let handle = self.mpv else { return } - + + // Clear wakeup callback first to stop event processing + if let handle = mpv { mpv_set_wakeup_callback(handle, nil, nil) - mpv_terminate_destroy(handle) - self.mpv = nil + + // Send quit command and drain events on the mpv queue + queue.sync { [weak self] in + guard let self, let handle = self.mpv else { return } + self.commandSync(handle, ["quit"]) + + // Drain any remaining events after quit + var drainCount = 0 + let maxDrain = 100 + while drainCount < maxDrain, let event = mpv_wait_event(handle, 0.1)?.pointee { + if event.event_id == MPV_EVENT_NONE || event.event_id == MPV_EVENT_SHUTDOWN { + break + } + drainCount += 1 + } + } + + // Call mpv_terminate_destroy on a background thread to avoid blocking main + // mpv_terminate_destroy may need main thread for AVFoundation cleanup, + // so we can't call it while blocking main with queue.sync + let handleToDestroy = handle + mpv = nil // Clear immediately so nothing else uses it + DispatchQueue.global(qos: .userInitiated).async { + mpv_terminate_destroy(handleToDestroy) + } } - + DispatchQueue.main.async { [weak self] in guard let self else { return } if #available(iOS 18.0, tvOS 17.0, *) { @@ -246,7 +289,7 @@ final class MPVLayerRenderer { self.displayLayer.flushAndRemoveImage() } } - + isStopping = false } @@ -402,7 +445,7 @@ final class MPVLayerRenderer { private func processEvents() { queue.async { [weak self] in guard let self else { return } - + while self.mpv != nil && !self.isStopping { guard let handle = self.mpv, let eventPointer = mpv_wait_event(handle, 0) else { return } From fb9b4b6f2d9f148bb7d5cb310610e566440e3fa8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 09:09:30 +0100 Subject: [PATCH 091/309] fix(tv): add padding to button --- components/home/Home.tv.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 2acdfdf94..d509f0957 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -550,6 +550,7 @@ export const Home = () => { color='black' onPress={retryCheck} justify='center' + className='px-4' iconRight={ retryLoading ? null : ( From 3a4042efd572c1943e53962d828ac7c73e903bae Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 23 Jan 2026 08:21:00 +0100 Subject: [PATCH 092/309] fix(tv): android tv black screen --- components/login/TVLogin.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index d632fe4d1..2b4348d85 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -9,12 +9,10 @@ import { Alert, Animated, Easing, - KeyboardAvoidingView, Pressable, ScrollView, View, } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; import { z } from "zod"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; @@ -414,8 +412,10 @@ export const TVLogin: React.FC = () => { const handleConnect = useCallback(async (url: string) => { url = url.trim().replace(/\/$/, ""); + console.log("[TVLogin] handleConnect called with:", url); try { const result = await checkUrl(url); + console.log("[TVLogin] checkUrl result:", result); if (result === undefined) { Alert.alert( t("login.connection_failed"), @@ -423,8 +423,12 @@ export const TVLogin: React.FC = () => { ); return; } + console.log("[TVLogin] Calling setServer with:", result); await setServer({ address: result }); - } catch {} + console.log("[TVLogin] setServer completed successfully"); + } catch (error) { + console.error("[TVLogin] Error in handleConnect:", error); + } }, []); const handleQuickConnect = async () => { @@ -445,9 +449,12 @@ export const TVLogin: React.FC = () => { } }; + // Debug logging + console.log("[TVLogin] Render - api?.basePath:", api?.basePath); + return ( - - + + {api?.basePath ? ( // ==================== CREDENTIALS SCREEN ==================== { )} - + {/* Save Account Modal */} { onDelete={handleServerActionDelete} onClose={() => setShowServerActionSheet(false)} /> - + ); }; From 566ff485fb4e758cf9f909ce879cd2834dd0b1b4 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:32:29 +0200 Subject: [PATCH 093/309] fix: scaling Hopefully fixing scaling across different TV types for android/ios Test for login screen at the moment Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- bun.lock | 110 ++++++++++++++++++++++++++++++++++- components/Button.tsx | 9 ++- components/common/Text.tsx | 2 +- components/login/TVInput.tsx | 9 +-- components/login/TVLogin.tsx | 70 ++++++++++++---------- package.json | 1 + 6 files changed, 163 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index dcbcbb953..27360de85 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "streamyfin", @@ -78,6 +77,7 @@ "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", + "react-native-responsive-sizes": "^2.1.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.18.0", "react-native-svg": "15.12.1", @@ -562,6 +562,8 @@ "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], + "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.79.7", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-CPJ995n1WIyi7KeLj+/aeFCe6MWQrRRXfMvBnc7XP4noSa4WEJfH8Zcvl/iWYVxrQdIaInadoiYLakeSflz5jg=="], + "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="], "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="], @@ -802,6 +804,10 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="], + + "caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -1240,6 +1246,8 @@ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1318,6 +1326,8 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1686,6 +1696,8 @@ "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="], + "react-native-responsive-sizes": ["react-native-responsive-sizes@2.1.0", "", { "dependencies": { "react-native": "^0.79.2" } }, "sha512-uxWi0IDj8CBGRh6KJyQ2RagWmLTWPWF5sDnVpM4jt/khwhEdaUeGa/q9rHcVHbb4o+oo1Zei9P3zIwbFc1UGcw=="], + "react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="], "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="], @@ -2280,6 +2292,8 @@ "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2432,6 +2446,8 @@ "react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "react-native-responsive-sizes/react-native": ["react-native@0.79.7", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.7", "@react-native/codegen": "0.79.7", "@react-native/community-cli-plugin": "0.79.7", "@react-native/gradle-plugin": "0.79.7", "@react-native/js-polyfills": "0.79.7", "@react-native/normalize-colors": "0.79.7", "@react-native/virtualized-lists": "0.79.7", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-7B2FJt/P+qulrkjWNttofiQjpZ5czSnL00kr6kQ9GpiykF/agX6Z2GVX6e5ggpQq2jqtyLvRtHIiUnKPYM77+w=="], + "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], @@ -2978,6 +2994,30 @@ "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "react-native-responsive-sizes/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.79.7", "", {}, "sha512-YeOXq8H5JZQbeIcAtHxmboDt02QG8ej8Z4SFVNh5UjaSb/0X1/v5/DhwNb4dfpIsQ5lFy75jeoSmUVp8qEKu9g=="], + + "react-native-responsive-sizes/react-native/@react-native/codegen": ["@react-native/codegen@0.79.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-uOjsqpLccl0+8iHPBmrkFrWwK0ctW28M83Ln2z43HRNubkxk5Nxd3DoyphFPL/BwTG79Ixu+BqpCS7b9mtizpw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.7", "", { "dependencies": { "@react-native/dev-middleware": "0.79.7", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-UQADqWfnKfEGMIyOa1zI8TMAOOLDdQ3h2FTCG8bp+MFGLAaJowaa+4GGb71A26fbg06/qnGy/Kr0Mv41IFGZnQ=="], + + "react-native-responsive-sizes/react-native/@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.7", "", {}, "sha512-vQqVthSs2EGqzV4KI0uFr/B4hUVXhVM86ekYL8iZCXzO6bewZa7lEUNGieijY0jc0a/mBJ6KZDzMtcUoS5vFRA=="], + + "react-native-responsive-sizes/react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.7", "", {}, "sha512-Djgvfz6AOa8ZEWyv+KA/UnP+ZruM+clCauFTR6NeRyD8YELvXGt+6A231SwpNdRkM7aTDMv0cM0NUbAMEPy+1A=="], + + "react-native-responsive-sizes/react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.7", "", {}, "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ=="], + + "react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="], + + "react-native-responsive-sizes/react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "react-native-responsive-sizes/react-native/metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="], + + "react-native-responsive-sizes/react-native/metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="], + + "react-native-responsive-sizes/react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + + "react-native-responsive-sizes/react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3162,6 +3202,26 @@ "metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-KHGPa7xwnKKWrzMnV1cHc8J56co4tFevmRvbjEbUCqkGS0s/l8ZxAGMR222/6YxZV3Eg1J3ywKQ8nHzTsTz5jw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="], + + "react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "react-native-responsive-sizes/react-native/metro-source-map/@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + + "react-native-responsive-sizes/react-native/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="], + + "react-native-responsive-sizes/react-native/metro-source-map/ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="], + "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3198,6 +3258,42 @@ "logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.7", "", {}, "sha512-91JVlhR6hDuJXcWTpCwcdEPlUQf+TckNG8BYfR4UkUOaZ87XahJv4EyWBeyfd8lwB/mh6nDJqbR6UiXwt5kbog=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="], + + "react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], @@ -3210,6 +3306,18 @@ "logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="], + "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="], + + "react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/components/Button.tsx b/components/Button.tsx index 03e9296d7..05ae5c5a9 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -14,6 +14,7 @@ import { TouchableOpacity, View, } from "react-native"; +import { fontSize, size } from "react-native-responsive-sizes"; import { useHaptic } from "@/hooks/useHaptic"; import { Loader } from "./Loader"; @@ -140,11 +141,15 @@ export const Button: React.FC> = ({ }} > - + {children} diff --git a/components/common/Text.tsx b/components/common/Text.tsx index 739177d75..6c6fee719 100644 --- a/components/common/Text.tsx +++ b/components/common/Text.tsx @@ -4,7 +4,7 @@ export function Text(props: TextProps) { if (Platform.isTV) return ( diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx index 40c2b8d3b..a575b3e4a 100644 --- a/components/login/TVInput.tsx +++ b/components/login/TVInput.tsx @@ -6,6 +6,7 @@ import { TextInput, type TextInputProps, } from "react-native"; +import { fontSize, size } from "react-native-responsive-sizes"; interface TVInputProps extends TextInputProps { label?: string; @@ -58,8 +59,8 @@ export const TVInput: React.FC = ({ @@ -69,8 +70,8 @@ export const TVInput: React.FC = ({ allowFontScaling={false} style={[ { - height: 68, - fontSize: 24, + height: size(200), + fontSize: fontSize(12), color: "#FFFFFF", }, style, diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 2b4348d85..1d8aab0df 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -13,6 +13,7 @@ import { ScrollView, View, } from "react-native"; +import { fontSize, size } from "react-native-responsive-sizes"; import { z } from "zod"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; @@ -66,7 +67,7 @@ const TVBackButton: React.FC<{ setIsFocused(false); animateFocus(false); }} - style={{ alignSelf: "flex-start", marginBottom: 40 }} + style={{ alignSelf: "flex-start", marginBottom: size(40) }} disabled={disabled} focusable={!disabled} > @@ -75,26 +76,25 @@ const TVBackButton: React.FC<{ transform: [{ scale }], flexDirection: "row", alignItems: "center", - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, + paddingHorizontal: size(12), + borderRadius: size(8), backgroundColor: isFocused ? "rgba(168, 85, 247, 0.2)" : "transparent", - borderWidth: 2, + borderWidth: size(2), borderColor: isFocused ? Colors.primary : "transparent", }} > {label} @@ -463,7 +463,7 @@ export const TVLogin: React.FC = () => { flexGrow: 1, justifyContent: "center", alignItems: "center", - paddingVertical: 60, + paddingVertical: size(20), }} showsVerticalScrollIndicator={false} > @@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => { style={{ width: "100%", maxWidth: 800, - paddingHorizontal: 60, + paddingHorizontal: size(40), }} > {/* Back Button */} @@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => { {/* Title */} {serverName ? ( @@ -501,16 +501,18 @@ export const TVLogin: React.FC = () => { {api.basePath} {/* Username Input - extra padding for focus scale */} - + { {/* Password Input */} - + { {/* Save Account Toggle */} - + { {/* Login Button */} - + - - ); - - return ( - - onToggle(isPlayed)} - /> - - ); -}; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 1b182dd4e..772ea0943 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -30,6 +30,7 @@ import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; +import { TVHeroCarousel } from "@/components/home/TVHeroCarousel"; import { Loader } from "@/components/Loader"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -41,8 +42,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; const HORIZONTAL_PADDING = 60; const TOP_PADDING = 100; -// Reduced gap since sections have internal padding for scale animations -const SECTION_GAP = 10; +// Generous gap between sections for Apple TV+ aesthetic +const SECTION_GAP = 24; type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; @@ -204,6 +205,57 @@ export const Home = () => { refetchInterval: 60 * 1000, }); + // Fetch hero items (Continue Watching + Next Up combined) + const { data: heroItems } = useQuery({ + queryKey: ["home", "heroItems", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Overview"], + startIndex: 0, + limit: 10, + }), + getTvShowsApi(api).getNextUp({ + userId: user.Id, + startIndex: 0, + limit: 10, + fields: ["Overview"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + // Combine, sort by recent activity, and dedupe + const combined = [...resumeItems, ...nextUpItems]; + const sorted = combined.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + + const seen = new Set(); + const deduped: BaseItemDto[] = []; + for (const item of sorted) { + if (!item.Id || seen.has(item.Id)) continue; + seen.add(item.Id); + deduped.push(item); + } + + return deduped.slice(0, 8); + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }); + const userViews = useMemo( () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), [data, settings?.hiddenLibraries], @@ -608,87 +660,106 @@ export const Home = () => { ); + // Determine if hero should be shown (separate setting from backdrop) + const showHero = + heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; + return ( - {/* Dynamic backdrop with crossfade */} - - {/* Layer 0 */} - - {layer0Url && ( - - )} - - {/* Layer 1 */} - - {layer1Url && ( - - )} - - {/* Gradient overlays for readability */} - - + > + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + {/* Gradient overlays for readability */} + + + )} - - {sections.map((section, index) => { + {/* Hero Carousel - Apple TV+ style featured content */} + {showHero && ( + + )} + + + {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */} + {sections.slice(showHero ? 1 : 0).map((section, index) => { // Render Streamystats sections after Recently Added sections // For default sections: place after Recently Added, before Suggested Movies (if present) // For custom sections: place at the very end const hasSuggestedMovies = !settings?.streamyStatsMovieRecommendations && !settings?.home?.sections; + // Adjust index calculation to account for sliced array when hero is shown + const displayedSectionsLength = + sections.length - (showHero ? 1 : 0); const streamystatsIndex = - sections.length - 1 - (hasSuggestedMovies ? 1 : 0); + displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0); const hasStreamystatsContent = settings.streamyStatsMovieRecommendations || settings.streamyStatsSeriesRecommendations || @@ -727,7 +798,8 @@ export const Home = () => { if (section.type === "InfiniteScrollingCollectionList") { const isHighPriority = section.priority === 1; - const isFirstSection = index === 0; + // First section only gets preferred focus if hero is not shown + const isFirstSection = index === 0 && !showHero; return ( ; - orientation?: "horizontal" | "vertical"; - pageSize?: number; -}; - -type MediaListSectionType = { - type: "MediaListSection"; - queryKey: (string | undefined)[]; - queryFn: QueryFunction; -}; - -type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; - -export const HomeWithCarousel = () => { - const router = useRouter(); - const { t } = useTranslation(); - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const insets = useSafeAreaInsets(); - const [_loading, setLoading] = useState(false); - const { settings, refreshStreamyfinPluginSettings } = useSettings(); - const headerOverlayOffset = Platform.isTV ? 0 : 60; - const navigation = useNavigation(); - const animatedScrollRef = useAnimatedRef(); - const scrollOffset = useScrollViewOffset(animatedScrollRef); - const { downloadedItems, cleanCacheDirectory } = useDownload(); - const prevIsConnected = useRef(false); - const { - isConnected, - serverConnected, - loading: retryLoading, - retryCheck, - } = useNetworkStatus(); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const [scrollY, setScrollY] = useState(0); - - useEffect(() => { - if (isConnected && !prevIsConnected.current) { - invalidateCache(); - } - prevIsConnected.current = isConnected; - }, [isConnected, invalidateCache]); - - const hasDownloads = useMemo(() => { - if (Platform.isTV) return false; - return downloadedItems.length > 0; - }, [downloadedItems]); - - useEffect(() => { - if (Platform.isTV) { - navigation.setOptions({ - headerLeft: () => null, - }); - return; - } - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className='ml-1.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }); - }, [navigation, router, hasDownloads]); - - useEffect(() => { - cleanCacheDirectory().catch((_e) => - console.error("Something went wrong cleaning cache directory"), - ); - }, []); - - const segments = useSegments(); - useEffect(() => { - const unsubscribe = eventBus.on("scrollToTop", () => { - if ((segments as string[])[2] === "(home)") - animatedScrollRef.current?.scrollTo({ - y: Platform.isTV ? -152 : -100, - animated: true, - }); - }); - - return () => { - unsubscribe(); - }; - }, [segments]); - - const { - data, - isError: e1, - isLoading: l1, - } = useQuery({ - queryKey: ["home", "userViews", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) { - return null; - } - - const response = await getUserViewsApi(api).getUserViews({ - userId: user.Id, - }); - - return response.data.Items || null; - }, - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, - }); - - const userViews = useMemo( - () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), - [data, settings?.hiddenLibraries], - ); - - const collections = useMemo(() => { - const allow = ["movies", "tvshows"]; - return ( - userViews?.filter( - (c) => c.CollectionType && allow.includes(c.CollectionType), - ) || [] - ); - }, [userViews]); - - const _refetch = async () => { - setLoading(true); - await refreshStreamyfinPluginSettings(); - await invalidateCache(); - setLoading(false); - }; - - const createCollectionConfig = useCallback( - ( - title: string, - queryKey: string[], - includeItemTypes: BaseItemKind[], - parentId: string | undefined, - pageSize: number = 10, - ): InfiniteScrollingCollectionListSection => ({ - title, - queryKey, - queryFn: async ({ pageParam = 0 }) => { - if (!api) return []; - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - limit: 100, // Fetch a larger set for pagination - fields: ["PrimaryImageAspectRatio", "Path", "Genres"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes, - parentId, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - }, - type: "InfiniteScrollingCollectionList", - pageSize, - }), - [api, user?.Id], - ); - - const defaultSections = useMemo(() => { - if (!api || !user?.Id) return []; - - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" || c.CollectionType === "movies" - ? [] - : ["Movie"]; - const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey: string[] = [ - "home", - `recentlyAddedIn${c.CollectionType}`, - user.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id, - 10, - ); - }); - - // Helper to sort items by most recent activity - const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { - return items.sort((a, b) => { - const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; - const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; - return new Date(dateB).getTime() - new Date(dateA).getTime(); - }); - }; - - // Helper to deduplicate items by ID - const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { - const seen = new Set(); - return items.filter((item) => { - if (!item.Id || seen.has(item.Id)) return false; - seen.add(item.Id); - return true; - }); - }; - - // Build the first sections based on merge setting - const firstSections: Section[] = settings.mergeNextUpAndContinueWatching - ? [ - { - title: t("home.continue_and_next_up"), - queryKey: ["home", "continueAndNextUp"], - queryFn: async ({ pageParam = 0 }) => { - // Fetch both in parallel - const [resumeResponse, nextUpResponse] = await Promise.all([ - getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: 0, - limit: 20, - }), - getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: 0, - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }), - ]); - - const resumeItems = resumeResponse.data.Items || []; - const nextUpItems = nextUpResponse.data.Items || []; - - // Combine, sort by recent activity, deduplicate - const combined = [...resumeItems, ...nextUpItems]; - const sorted = sortByRecentActivity(combined); - const deduplicated = deduplicateById(sorted); - - // Paginate client-side - return deduplicated.slice(pageParam, pageParam + 10); - }, - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ] - : [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async ({ pageParam = 0 }) => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: pageParam, - limit: 10, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async ({ pageParam = 0 }) => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: 10, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ]; - - const ss: Section[] = [ - ...firstSections, - ...latestMediaViews, - // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled - ...(!settings?.streamyStatsMovieRecommendations - ? [ - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - startIndex: pageParam, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList" as const, - orientation: "vertical" as const, - pageSize: 10, - }, - ] - : []), - ]; - return ss; - }, [ - api, - user?.Id, - collections, - t, - createCollectionConfig, - settings?.streamyStatsMovieRecommendations, - settings.mergeNextUpAndContinueWatching, - ]); - - const customSections = useMemo(() => { - if (!api || !user?.Id || !settings?.home?.sections) return []; - const ss: Section[] = []; - settings.home.sections.forEach((section, index) => { - const id = section.title || `section-${index}`; - const pageSize = 10; - ss.push({ - title: t(`${id}`), - queryKey: ["home", "custom", String(index), section.title ?? null], - queryFn: async ({ pageParam = 0 }) => { - if (section.items) { - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - startIndex: pageParam, - limit: section.items?.limit || pageSize, - recursive: true, - includeItemTypes: section.items?.includeItemTypes, - sortBy: section.items?.sortBy, - sortOrder: section.items?.sortOrder, - filters: section.items?.filters, - parentId: section.items?.parentId, - }); - return response.data.Items || []; - } - if (section.nextUp) { - const response = await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: section.nextUp?.limit || pageSize, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: section.nextUp?.enableResumable, - enableRewatching: section.nextUp?.enableRewatching, - }); - return response.data.Items || []; - } - if (section.latest) { - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 100, // Fetch larger set - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - } - if (section.custom) { - const response = await api.get( - section.custom.endpoint, - { - params: { - ...(section.custom.query || {}), - userId: user?.Id, - startIndex: pageParam, - limit: pageSize, - }, - headers: section.custom.headers || {}, - }, - ); - return response.data.Items || []; - } - return []; - }, - type: "InfiniteScrollingCollectionList", - orientation: section?.orientation || "vertical", - pageSize, - }); - }); - return ss; - }, [api, user?.Id, settings?.home?.sections, t]); - - const sections = settings?.home?.sections ? customSections : defaultSections; - - if (!isConnected || serverConnected !== true) { - let title = ""; - let subtitle = ""; - - if (!isConnected) { - title = t("home.no_internet"); - subtitle = t("home.no_internet_message"); - } else if (serverConnected === null) { - title = t("home.checking_server_connection"); - subtitle = t("home.checking_server_connection_message"); - } else if (!serverConnected) { - title = t("home.server_unreachable"); - subtitle = t("home.server_unreachable_message"); - } - return ( - - {title} - {subtitle} - - - {!Platform.isTV && ( - - )} - - - - - ); - } - - if (e1) - return ( - - {t("home.oops")} - - {t("home.error_message")} - - - ); - - if (l1) - return ( - - - - ); - - return ( - { - setScrollY(event.nativeEvent.contentOffset.y); - }} - > - - - - {sections.map((section, index) => { - // Render Streamystats sections after Continue Watching and Next Up - // When merged, they appear after index 0; otherwise after index 1 - const streamystatsIndex = settings.mergeNextUpAndContinueWatching - ? 0 - : 1; - const hasStreamystatsContent = - settings.streamyStatsMovieRecommendations || - settings.streamyStatsSeriesRecommendations || - settings.streamyStatsPromotedWatchlists; - const streamystatsSections = - index === streamystatsIndex && hasStreamystatsContent ? ( - <> - {settings.streamyStatsMovieRecommendations && ( - - )} - {settings.streamyStatsSeriesRecommendations && ( - - )} - {settings.streamyStatsPromotedWatchlists && ( - - )} - - ) : null; - - if (section.type === "InfiniteScrollingCollectionList") { - return ( - - - {streamystatsSections} - - ); - } - if (section.type === "MediaListSection") { - return ( - - - {streamystatsSections} - - ); - } - return null; - })} - - - - - ); -}; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 9fdbd0dbe..ef7bcae70 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -28,7 +28,7 @@ import ContinueWatchingPoster, { } from "../ContinueWatchingPoster.tv"; import SeriesPoster from "../posters/SeriesPoster.tv"; -const ITEM_GAP = 16; +const ITEM_GAP = 24; // Extra padding to accommodate scale animation (1.05x) and glow shadow const SCALE_PADDING = 20; @@ -365,11 +365,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({ {/* Section Header */} {title} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 59f9c640a..1bb168a8b 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -155,11 +155,12 @@ const WatchlistSection: React.FC = ({ {watchlist.name} diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index 792933945..ec58f0d6d 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -218,11 +218,12 @@ export const StreamystatsRecommendations: React.FC = ({ {title} diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx new file mode 100644 index 000000000..f0e34c75d --- /dev/null +++ b/components/home/TVHeroCarousel.tsx @@ -0,0 +1,569 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Animated, + Dimensions, + Easing, + FlatList, + Pressable, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ProgressBar } from "@/components/common/ProgressBar"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); +const HERO_HEIGHT = SCREEN_HEIGHT * 0.62; +const CARD_WIDTH = 280; +const CARD_GAP = 24; +const CARD_PADDING = 60; + +interface TVHeroCarouselProps { + items: BaseItemDto[]; + onItemFocus?: (item: BaseItemDto) => void; +} + +interface HeroCardProps { + item: BaseItemDto; + isFirst: boolean; + onFocus: (item: BaseItemDto) => void; + onPress: (item: BaseItemDto) => void; +} + +const HeroCard: React.FC = React.memo( + ({ item, isFirst, onFocus, onPress }) => { + const api = useAtomValue(apiAtom); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const posterUrl = useMemo(() => { + if (!api) return null; + // Try thumb first, then primary + if (item.ImageTags?.Thumb) { + return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; + } + if (item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; + } + // For episodes, use series thumb + if (item.Type === "Episode" && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + } + return null; + }, [api, item]); + + const animateTo = useCallback( + (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(), + [scale], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + animateTo(1.08); + onFocus(item); + }, [animateTo, onFocus, item]); + + const handleBlur = useCallback(() => { + setFocused(false); + animateTo(1); + }, [animateTo]); + + const handlePress = useCallback(() => { + onPress(item); + }, [onPress, item]); + + return ( + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + ); + }, +); + +// Debounce delay to prevent rapid backdrop changes when navigating fast +const BACKDROP_DEBOUNCE_MS = 300; + +export const TVHeroCarousel: React.FC = ({ + items, + onItemFocus, +}) => { + const api = useAtomValue(apiAtom); + const insets = useSafeAreaInsets(); + const router = useRouter(); + + // Active item for featured display (debounced) + const [activeItem, setActiveItem] = useState( + items[0] || null, + ); + const debounceTimerRef = useRef | null>(null); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Crossfade animation state + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(0)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + // Get backdrop URL for active item + const backdropUrl = useMemo(() => { + if (!activeItem) return null; + return getBackdropUrl({ + api, + item: activeItem, + quality: 90, + width: 1920, + }); + }, [api, activeItem]); + + // Get logo URL for active item + const logoUrl = useMemo(() => { + if (!activeItem) return null; + return getLogoImageUrlById({ api, item: activeItem }); + }, [api, activeItem]); + + // Crossfade effect for backdrop + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + try { + await Image.prefetch(backdropUrl); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + + // Handle card focus with debounce + const handleCardFocus = useCallback( + (item: BaseItemDto) => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + // Set new timer to update active item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setActiveItem(item); + onItemFocus?.(item); + }, BACKDROP_DEBOUNCE_MS); + }, + [onItemFocus], + ); + + // Handle card press - navigate to item + const handleCardPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, "(home)"); + router.push(navigation as any); + }, + [router], + ); + + // Get metadata for active item + const year = activeItem?.ProductionYear; + const duration = activeItem?.RunTimeTicks + ? runtimeTicksToMinutes(activeItem.RunTimeTicks) + : null; + const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0; + const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0; + + // Get display title + const displayTitle = useMemo(() => { + if (!activeItem) return ""; + if (activeItem.Type === "Episode") { + return activeItem.SeriesName || activeItem.Name || ""; + } + return activeItem.Name || ""; + }, [activeItem]); + + // Get subtitle for episodes + const episodeSubtitle = useMemo(() => { + if (!activeItem || activeItem.Type !== "Episode") return null; + return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`; + }, [activeItem]); + + // Memoize hero items to prevent re-renders + const heroItems = useMemo(() => items.slice(0, 8), [items]); + + // Memoize renderItem for FlatList + const renderHeroCard = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + ), + [handleCardFocus, handleCardPress], + ); + + // Memoize keyExtractor + const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []); + + if (items.length === 0) return null; + + return ( + + {/* Backdrop layers with crossfade */} + + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + + {/* Gradient overlays */} + + + + + {/* Content overlay */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {displayTitle} + + )} + + {/* Episode subtitle */} + {episodeSubtitle && ( + + {episodeSubtitle} + + )} + + {/* Description */} + {activeItem?.Overview && ( + + {activeItem.Overview} + + )} + + {/* Metadata badges */} + + {year && ( + + {year} + + )} + {duration && ( + + {duration} + + )} + {activeItem?.OfficialRating && ( + + + {activeItem.OfficialRating} + + + )} + {hasProgress && ( + + + + + + {Math.round(playedPercent)}% + + + )} + + + {/* Thumbnail carousel */} + + + + ); +}; diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx index 1719df962..46f255230 100644 --- a/components/posters/MoviePoster.tv.tsx +++ b/components/posters/MoviePoster.tv.tsx @@ -7,7 +7,7 @@ import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 210; +export const TV_POSTER_WIDTH = 260; type MoviePosterProps = { item: BaseItemDto; @@ -24,7 +24,7 @@ const MoviePoster: React.FC = ({ return getPrimaryImageUrl({ api, item, - width: 420, // 2x for quality on large screens + width: 520, // 2x for quality on large screens }); }, [api, item]); diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx index 21b41ff61..49c43cef1 100644 --- a/components/posters/SeriesPoster.tv.tsx +++ b/components/posters/SeriesPoster.tv.tsx @@ -6,7 +6,7 @@ import { View } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 210; +export const TV_POSTER_WIDTH = 260; type SeriesPosterProps = { item: BaseItemDto; @@ -18,12 +18,12 @@ const SeriesPoster: React.FC = ({ item }) => { const url = useMemo(() => { if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`; + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`; } return getPrimaryImageUrl({ api, item, - width: 420, // 2x for quality on large screens + width: 520, // 2x for quality on large screens }); }, [api, item]); diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx index f9074213e..844096177 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index fcca24988..7abf10fbd 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - router.push("/settings/hide-libraries/page")} title={t("home.settings.other.hide_libraries")} diff --git a/translations/en.json b/translations/en.json index 2a6292670..855d1cf61 100644 --- a/translations/en.json +++ b/translations/en.json @@ -125,7 +125,8 @@ "title": "Appearance", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "hide_remote_session_button": "Hide Remote Session Button", - "show_home_backdrop": "Dynamic Home Backdrop" + "show_home_backdrop": "Dynamic Home Backdrop", + "show_hero_carousel": "Hero Carousel" }, "network": { "title": "Network", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 21ac35772..3038199b4 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -198,10 +198,10 @@ export type Settings = { hideVolumeSlider: boolean; hideBrightnessSlider: boolean; usePopularPlugin: boolean; - showLargeHomeCarousel: boolean; mergeNextUpAndContinueWatching: boolean; // TV-specific settings showHomeBackdrop: boolean; + showTVHeroCarousel: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -287,10 +287,10 @@ export const defaultValues: Settings = { hideVolumeSlider: false, hideBrightnessSlider: false, usePopularPlugin: true, - showLargeHomeCarousel: false, mergeNextUpAndContinueWatching: false, // TV-specific settings showHomeBackdrop: true, + showTVHeroCarousel: true, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, From 2c6938c739459cb5ad491721fee639495cb1640d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 14:50:05 +0100 Subject: [PATCH 102/309] fix: design --- components/home/TVHeroCarousel.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index f0e34c75d..a5f57038e 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -82,7 +82,7 @@ const HeroCard: React.FC = React.memo( const handleFocus = useCallback(() => { setFocused(true); - animateTo(1.08); + animateTo(1.1); onFocus(item); }, [animateTo, onFocus, item]); @@ -110,11 +110,9 @@ const HeroCard: React.FC = React.memo( borderRadius: 16, overflow: "hidden", transform: [{ scale }], - borderWidth: focused ? 4 : 0, - borderColor: "#FFFFFF", shadowColor: "#FFFFFF", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.5 : 0, + shadowOpacity: focused ? 0.6 : 0, shadowRadius: focused ? 20 : 0, }} > From c2d61654b00471324065155c7e45a704c582440c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 17:02:10 +0100 Subject: [PATCH 103/309] feat(tv): add glass poster module and refactor grid layouts --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 126 ++++++----- .../(tabs)/(watchlists)/[watchlistId].tsx | 161 +++++++++++++-- components/ContinueWatchingPoster.tv.tsx | 60 ++++++ components/home/TVHeroCarousel.tsx | 35 ++++ components/posters/MoviePoster.tv.tsx | 24 +++ components/posters/SeriesPoster.tv.tsx | 23 +++ components/search/TVSearchPage.tsx | 14 +- components/search/TVSearchSection.tsx | 78 +++++-- components/tv/TVFocusablePoster.tsx | 4 +- components/tv/TVSeriesSeasonCard.tsx | 94 ++++++--- modules/glass-poster/expo-module.config.json | 6 + modules/glass-poster/index.ts | 8 + modules/glass-poster/ios/GlassPoster.podspec | 23 +++ .../ios/GlassPosterExpoView.swift | 91 ++++++++ .../glass-poster/ios/GlassPosterModule.swift | 50 +++++ .../glass-poster/ios/GlassPosterView.swift | 195 ++++++++++++++++++ modules/glass-poster/src/GlassPoster.types.ts | 26 +++ modules/glass-poster/src/GlassPosterModule.ts | 36 ++++ modules/glass-poster/src/GlassPosterView.tsx | 46 +++++ modules/glass-poster/src/index.ts | 6 + modules/index.ts | 4 +- 21 files changed, 980 insertions(+), 130 deletions(-) create mode 100644 modules/glass-poster/expo-module.config.json create mode 100644 modules/glass-poster/index.ts create mode 100644 modules/glass-poster/ios/GlassPoster.podspec create mode 100644 modules/glass-poster/ios/GlassPosterExpoView.swift create mode 100644 modules/glass-poster/ios/GlassPosterModule.swift create mode 100644 modules/glass-poster/ios/GlassPosterView.swift create mode 100644 modules/glass-poster/src/GlassPoster.types.ts create mode 100644 modules/glass-poster/src/GlassPosterModule.ts create mode 100644 modules/glass-poster/src/GlassPosterView.tsx create mode 100644 modules/glass-poster/src/index.ts diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 3f0734fa2..4f1bbfbd1 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -15,7 +15,13 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { FlatList, Platform, useWindowDimensions, View } from "react-native"; +import { + FlatList, + Platform, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { @@ -64,8 +70,9 @@ import { import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; -const TV_ITEM_GAP = 16; -const TV_SCALE_PADDING = 20; +const TV_ITEM_GAP = 20; +const TV_HORIZONTAL_PADDING = 60; +const _TV_SCALE_PADDING = 20; const Page = () => { const searchParams = useLocalSearchParams() as { @@ -223,12 +230,8 @@ const Page = () => { const nrOfCols = useMemo(() => { if (Platform.isTV) { - // Calculate columns based on TV poster width + gap - const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; - return Math.max( - 1, - Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), - ); + // TV uses flexWrap, so nrOfCols is just for mobile + return 1; } if (screenWidth < 300) return 2; if (screenWidth < 500) return 3; @@ -394,7 +397,7 @@ const Page = () => { ); const renderTVItem = useCallback( - ({ item }: { item: BaseItemDto }) => { + (item: BaseItemDto) => { const handlePress = () => { const navTarget = getItemNavigation(item, "(libraries)"); router.push(navTarget as any); @@ -402,9 +405,8 @@ const Page = () => { return ( @@ -843,15 +845,32 @@ const Page = () => { // TV return with filter bar return ( - - {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */} + { + // Load more when near bottom + const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; + const isNearBottom = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - 500; + if (isNearBottom && hasNextPage && !isFetching) { + fetchNextPage(); + } + }} + scrollEventThrottle={400} + > + {/* Filter bar */} @@ -918,45 +937,40 @@ const Page = () => { /> - {/* Grid - using FlatList instead of FlashList to fix focus issues */} - - - {t("library.no_results")} - - - } - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderTVItem} - extraData={[orientation, nrOfCols]} - keyExtractor={keyExtractor} - numColumns={nrOfCols} - removeClippedSubviews={false} - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); - } - }} - onEndReachedThreshold={1} - contentContainerStyle={{ - paddingBottom: 24, - paddingLeft: TV_SCALE_PADDING, - paddingRight: TV_SCALE_PADDING, - paddingTop: 20, - }} - ItemSeparatorComponent={() => ( - - )} - /> - + {/* Grid with flexWrap */} + {flatData.length === 0 ? ( + + + {t("library.no_results")} + + + ) : ( + + {flatData.map((item) => renderTVItem(item))} + + )} + + {/* Loading indicator */} + {isFetching && ( + + + + )} + ); }; diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index b8a311905..2ee4592c2 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -10,6 +10,7 @@ import { Alert, Platform, RefreshControl, + ScrollView, TouchableOpacity, useWindowDimensions, View, @@ -28,6 +29,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; import { @@ -41,15 +43,24 @@ import { import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { userAtom } from "@/providers/JellyfinProvider"; -const TV_ITEM_GAP = 16; -const TV_SCALE_PADDING = 20; +const TV_ITEM_GAP = 20; +const TV_HORIZONTAL_PADDING = 60; const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( - + {item.Name} - + {item.ProductionYear} @@ -70,14 +81,8 @@ export default function WatchlistDetailScreen() { : undefined; const nrOfCols = useMemo(() => { - if (Platform.isTV) { - // Calculate columns based on TV poster width + gap - const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; - return Math.max( - 1, - Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), - ); - } + // TV uses flexWrap, so nrOfCols is just for mobile + if (Platform.isTV) return 1; if (screenWidth < 300) return 2; if (screenWidth < 500) return 3; if (screenWidth < 800) return 5; @@ -185,7 +190,7 @@ export default function WatchlistDetailScreen() { ); const renderTVItem = useCallback( - ({ item, index }: { item: BaseItemDto; index: number }) => { + (item: BaseItemDto, index: number) => { const handlePress = () => { const navigation = getItemNavigation(item, "(watchlists)"); router.push(navigation as any); @@ -193,9 +198,8 @@ export default function WatchlistDetailScreen() { return ( @@ -328,6 +332,126 @@ export default function WatchlistDetailScreen() { ); } + // TV layout with ScrollView + flexWrap + if (Platform.isTV) { + return ( + + {/* Header */} + + {watchlist.description && ( + + {watchlist.description} + + )} + + + + + {items?.length ?? 0}{" "} + {(items?.length ?? 0) === 1 + ? t("watchlists.item") + : t("watchlists.items")} + + + + + + {watchlist.isPublic + ? t("watchlists.public") + : t("watchlists.private")} + + + {!isOwner && ( + + {t("watchlists.by_owner")} + + )} + + + + {/* Grid with flexWrap */} + {!items || items.length === 0 ? ( + + + + {t("watchlists.empty_watchlist")} + + + ) : ( + + {items.map((item, index) => renderTVItem(item, index))} + + )} + + ); + } + + // Mobile layout with FlashList return ( } - renderItem={Platform.isTV ? renderTVItem : renderItem} + renderItem={renderItem} ItemSeparatorComponent={() => ( = ({ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; }, [api, item, useEpisodePoster]); + const progress = useMemo(() => { + if (item.Type === "Program") { + if (!item.StartDate || !item.EndDate) { + return 0; + } + const startDate = new Date(item.StartDate); + const endDate = new Date(item.EndDate); + const now = new Date(); + const total = endDate.getTime() - startDate.getTime(); + if (total <= 0) { + return 0; + } + const elapsed = now.getTime() - startDate.getTime(); + return (elapsed / total) * 100; + } + return item.UserData?.PlayedPercentage || 0; + }, [item]); + + const isWatched = item.UserData?.Played === true; + + // Use glass effect on tvOS 26+ + const useGlass = isGlassEffectAvailable(); + if (!url) { return ( = ({ ); } + if (useGlass) { + return ( + + + {showPlayButton && ( + + + + )} + + ); + } + + // Fallback for older tvOS versions return ( = React.memo( const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; + // Check if glass effect is available (tvOS 26+) + const useGlass = Platform.OS === "ios" && isGlassEffectAvailable(); + const posterUrl = useMemo(() => { if (!api) return null; // Try thumb first, then primary @@ -69,6 +77,8 @@ const HeroCard: React.FC = React.memo( return null; }, [api, item]); + const progress = item.UserData?.PlayedPercentage || 0; + const animateTo = useCallback( (value: number) => Animated.timing(scale, { @@ -95,6 +105,31 @@ const HeroCard: React.FC = React.memo( onPress(item); }, [onPress, item]); + // Use glass poster for tvOS 26+ + if (useGlass) { + return ( + + + + ); + } + + // Fallback for non-tvOS or older tvOS return ( = ({ }, [api, item]); const progress = item.UserData?.PlayedPercentage || 0; + const isWatched = item.UserData?.Played === true; const blurhash = useMemo(() => { const key = item.ImageTags?.Primary as string; return item.ImageBlurHashes?.Primary?.[key]; }, [item]); + // Use glass effect on tvOS 26+ + const useGlass = isGlassEffectAvailable(); + + if (useGlass) { + return ( + + ); + } + + // Fallback for older tvOS versions return ( = ({ item }) => { return item.ImageBlurHashes?.Primary?.[key]; }, [item]); + // Use glass effect on tvOS 26+ + const useGlass = isGlassEffectAvailable(); + + if (useGlass) { + return ( + + ); + } + + // Fallback for older tvOS versions return ( { color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: 16, + fontSize: TVTypography.callout, }} numberOfLines={1} > @@ -222,7 +223,7 @@ export const TVSearchPage: React.FC = ({ }} > {/* Search Input */} - + = ({ = ({ > {t("search.no_results_found_for")} - + "{debouncedSearch}" diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index 9f2152c54..695c64ccd 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -11,6 +11,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVTypography } from "@/constants/TVTypography"; const ITEM_GAP = 16; const SCALE_PADDING = 20; @@ -21,12 +22,19 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { {item.Type === "Episode" ? ( <> - + {item.Name} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {" - "} @@ -36,53 +44,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { ) : item.Type === "MusicArtist" ? ( {item.Name} ) : item.Type === "MusicAlbum" ? ( <> - + {item.Name} {item.AlbumArtist || item.Artists?.join(", ")} ) : item.Type === "Audio" ? ( <> - + {item.Name} {item.Artists?.join(", ") || item.AlbumArtist} ) : item.Type === "Playlist" ? ( <> - + {item.Name} - + {item.ChildCount} tracks ) : item.Type === "Person" ? ( - + {item.Name} ) : ( <> - + {item.Name} - + {item.ProductionYear} @@ -311,11 +358,12 @@ export const TVSearchSection: React.FC = ({ {/* Section Header */} {title} diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx index 3ae0e2149..fe2ab9f6b 100644 --- a/components/tv/TVFocusablePoster.tsx +++ b/components/tv/TVFocusablePoster.tsx @@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC = ({ transform: [{ scale }], shadowColor, shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.6 : 0, - shadowRadius: focused ? 20 : 0, + shadowOpacity: focused ? 0.3 : 0, + shadowRadius: focused ? 12 : 0, }, style, ]} diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx index eb69f7f8a..81b9772b5 100644 --- a/components/tv/TVSeriesSeasonCard.tsx +++ b/components/tv/TVSeriesSeasonCard.tsx @@ -1,9 +1,13 @@ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; import React from "react"; -import { Animated, Pressable, View } from "react-native"; +import { Animated, Platform, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; import { TVTypography } from "@/constants/TVTypography"; +import { + GlassPosterView, + isGlassEffectAvailable, +} from "@/modules/glass-poster"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVSeriesSeasonCardProps { @@ -24,6 +28,59 @@ export const TVSeriesSeasonCard: React.FC = ({ const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); + // Check if glass effect is available (tvOS 26+) + const useGlass = Platform.OS === "ios" && isGlassEffectAvailable(); + + const renderPoster = () => { + if (useGlass) { + return ( + + ); + } + + return ( + + {imageUrl ? ( + + ) : ( + + + + )} + + ); + }; + return ( = ({ width: 210, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.5 : 0, - shadowRadius: focused ? 20 : 0, + shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0, + shadowRadius: useGlass ? 0 : focused ? 20 : 0, }, ]} > - - {imageUrl ? ( - - ) : ( - - - - )} - + {renderPoster()} '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9' + } + + s.source_files = "*.{h,m,mm,swift}" +end diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift new file mode 100644 index 000000000..1c3341902 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterExpoView.swift @@ -0,0 +1,91 @@ +import ExpoModulesCore +import SwiftUI +import UIKit + +/// ExpoView wrapper that hosts the SwiftUI GlassPosterView +class GlassPosterExpoView: ExpoView { + private var hostingController: UIHostingController? + private var posterView: GlassPosterView + + // Stored dimensions for intrinsic content size + private var posterWidth: CGFloat = 260 + private var posterAspectRatio: CGFloat = 10.0 / 15.0 + + // Event dispatchers + let onLoad = EventDispatcher() + let onError = EventDispatcher() + + required init(appContext: AppContext? = nil) { + self.posterView = GlassPosterView() + super.init(appContext: appContext) + setupHostingController() + } + + private func setupHostingController() { + let hostingController = UIHostingController(rootView: posterView) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + self.hostingController = hostingController + } + + private func updateHostingController() { + hostingController?.rootView = posterView + } + + // Override intrinsic content size for proper React Native layout + override var intrinsicContentSize: CGSize { + let height = posterWidth / posterAspectRatio + return CGSize(width: posterWidth, height: height) + } + + // MARK: - Property Setters + + func setImageUrl(_ url: String?) { + posterView.imageUrl = url + updateHostingController() + } + + func setAspectRatio(_ ratio: Double) { + posterView.aspectRatio = ratio + posterAspectRatio = CGFloat(ratio) + invalidateIntrinsicContentSize() + updateHostingController() + } + + func setWidth(_ width: Double) { + posterView.width = width + posterWidth = CGFloat(width) + invalidateIntrinsicContentSize() + updateHostingController() + } + + func setCornerRadius(_ radius: Double) { + posterView.cornerRadius = radius + updateHostingController() + } + + func setProgress(_ progress: Double) { + posterView.progress = progress + updateHostingController() + } + + func setShowWatchedIndicator(_ show: Bool) { + posterView.showWatchedIndicator = show + updateHostingController() + } + + func setIsFocused(_ focused: Bool) { + posterView.isFocused = focused + updateHostingController() + } +} diff --git a/modules/glass-poster/ios/GlassPosterModule.swift b/modules/glass-poster/ios/GlassPosterModule.swift new file mode 100644 index 000000000..3b9b9b194 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterModule.swift @@ -0,0 +1,50 @@ +import ExpoModulesCore + +public class GlassPosterModule: Module { + public func definition() -> ModuleDefinition { + Name("GlassPoster") + + // Check if glass effect is available (tvOS 26+) + Function("isGlassEffectAvailable") { () -> Bool in + #if os(tvOS) + if #available(tvOS 26.0, *) { + return true + } + #endif + return false + } + + // Native view component + View(GlassPosterExpoView.self) { + Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in + view.setImageUrl(url) + } + + Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in + view.setAspectRatio(ratio) + } + + Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in + view.setCornerRadius(radius) + } + + Prop("progress") { (view: GlassPosterExpoView, progress: Double) in + view.setProgress(progress) + } + + Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in + view.setShowWatchedIndicator(show) + } + + Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in + view.setIsFocused(focused) + } + + Prop("width") { (view: GlassPosterExpoView, width: Double) in + view.setWidth(width) + } + + Events("onLoad", "onError") + } + } +} diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift new file mode 100644 index 000000000..77c5efb83 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -0,0 +1,195 @@ +import SwiftUI + +/// SwiftUI view with tvOS 26 Liquid Glass effect +struct GlassPosterView: View { + var imageUrl: String? = nil + var aspectRatio: Double = 10.0 / 15.0 + var cornerRadius: Double = 24 + var progress: Double = 0 + var showWatchedIndicator: Bool = false + var isFocused: Bool = false + var width: Double = 260 + + // Internal focus state for tvOS + @FocusState private var isInternallyFocused: Bool + + // Combined focus state (external prop OR internal focus) + private var isCurrentlyFocused: Bool { + isFocused || isInternallyFocused + } + + // Calculated height based on width and aspect ratio + private var height: Double { + width / aspectRatio + } + + var body: some View { + #if os(tvOS) + if #available(tvOS 26.0, *) { + glassContent + } else { + fallbackContent + } + #else + fallbackContent + #endif + } + + // MARK: - tvOS 26+ Glass Effect + + #if os(tvOS) + @available(tvOS 26.0, *) + private var glassContent: some View { + return ZStack { + // Image content + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .focusable() + .focused($isInternallyFocused) + .scaleEffect(isCurrentlyFocused ? 1.08 : 1.0) + .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused) + } + #endif + + // MARK: - Fallback for older tvOS versions + + private var fallbackContent: some View { + ZStack { + // Main image + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + + // Subtle overlay for depth + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial.opacity(0.15)) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .scaleEffect(isFocused ? 1.08 : 1.0) + .animation(.easeOut(duration: 0.15), value: isFocused) + } + + // MARK: - Shared Components + + private var imageContent: some View { + Group { + if let urlString = imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholderView + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + placeholderView + @unknown default: + placeholderView + } + } + } else { + placeholderView + } + } + } + + private var placeholderView: some View { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + + private var progressOverlay: some View { + VStack { + Spacer() + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background track + Rectangle() + .fill(Color.white.opacity(0.3)) + .frame(height: 4) + + // Progress fill + Rectangle() + .fill(Color.white) + .frame(width: geometry.size.width * CGFloat(progress / 100), height: 4) + } + } + .frame(height: 4) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + + private var watchedIndicatorOverlay: some View { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: 28, height: 28) + + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + } + .padding(8) + } + Spacer() + } + } +} + +// MARK: - Preview + +#if DEBUG +struct GlassPosterView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 10.0 / 15.0, + cornerRadius: 24, + progress: 45, + showWatchedIndicator: false, + isFocused: true, + width: 260 + ) + + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 16.0 / 9.0, + cornerRadius: 24, + progress: 75, + showWatchedIndicator: true, + isFocused: false, + width: 400 + ) + } + .padding() + .background(Color.black) + } +} +#endif diff --git a/modules/glass-poster/src/GlassPoster.types.ts b/modules/glass-poster/src/GlassPoster.types.ts new file mode 100644 index 000000000..8878779b1 --- /dev/null +++ b/modules/glass-poster/src/GlassPoster.types.ts @@ -0,0 +1,26 @@ +import type { StyleProp, ViewStyle } from "react-native"; + +export interface GlassPosterViewProps { + /** URL of the image to display */ + imageUrl: string | null; + /** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */ + aspectRatio: number; + /** Corner radius in points. Default: 24 */ + cornerRadius: number; + /** Progress percentage (0-100). Shows progress bar at bottom when > 0 */ + progress: number; + /** Whether to show the watched checkmark indicator */ + showWatchedIndicator: boolean; + /** Whether the poster is currently focused (for scale animation) */ + isFocused: boolean; + /** Width of the poster in points. Required for proper sizing. */ + width: number; + /** Style for the container view */ + style?: StyleProp; + /** Called when the image loads successfully */ + onLoad?: () => void; + /** Called when image loading fails */ + onError?: (error: string) => void; +} + +export type GlassPosterModuleEvents = Record; diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts new file mode 100644 index 000000000..58ac4a258 --- /dev/null +++ b/modules/glass-poster/src/GlassPosterModule.ts @@ -0,0 +1,36 @@ +import { NativeModule, requireNativeModule } from "expo"; +import { Platform } from "react-native"; + +import type { GlassPosterModuleEvents } from "./GlassPoster.types"; + +declare class GlassPosterModuleType extends NativeModule { + isGlassEffectAvailable(): boolean; +} + +// Only load the native module on tvOS +let GlassPosterNativeModule: GlassPosterModuleType | null = null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + GlassPosterNativeModule = + requireNativeModule("GlassPoster"); + } catch { + // Module not available, will use fallback + } +} + +/** + * Check if the native glass effect is available (tvOS 26+) + */ +export function isGlassEffectAvailable(): boolean { + if (!GlassPosterNativeModule) { + return false; + } + try { + return GlassPosterNativeModule.isGlassEffectAvailable(); + } catch { + return false; + } +} + +export default GlassPosterNativeModule; diff --git a/modules/glass-poster/src/GlassPosterView.tsx b/modules/glass-poster/src/GlassPosterView.tsx new file mode 100644 index 000000000..0ec104f5c --- /dev/null +++ b/modules/glass-poster/src/GlassPosterView.tsx @@ -0,0 +1,46 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { Platform, View } from "react-native"; + +import type { GlassPosterViewProps } from "./GlassPoster.types"; +import { isGlassEffectAvailable } from "./GlassPosterModule"; + +// Only require the native view on tvOS +let NativeGlassPosterView: React.ComponentType | null = + null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + NativeGlassPosterView = + requireNativeView("GlassPoster"); + } catch { + // Module not available + } +} + +/** + * GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+ + * + * On tvOS 26+: Renders with native Liquid Glass effect + * On older tvOS: Renders with subtle glass-like material effect + * On other platforms: Returns null (use existing poster components) + */ +const GlassPosterView: React.FC = (props) => { + // Only render on tvOS + if (!Platform.isTV || Platform.OS !== "ios") { + return null; + } + + // Use native view if available + if (NativeGlassPosterView) { + return ; + } + + // Fallback: return empty view (caller should handle this) + return ; +}; + +export default GlassPosterView; + +// Re-export availability check for convenience +export { isGlassEffectAvailable }; diff --git a/modules/glass-poster/src/index.ts b/modules/glass-poster/src/index.ts new file mode 100644 index 000000000..eee2be164 --- /dev/null +++ b/modules/glass-poster/src/index.ts @@ -0,0 +1,6 @@ +export * from "./GlassPoster.types"; +export { + default as GlassPosterModule, + isGlassEffectAvailable, +} from "./GlassPosterModule"; +export { default as GlassPosterView } from "./GlassPosterView"; diff --git a/modules/index.ts b/modules/index.ts index e026be73b..d93e9077a 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -7,7 +7,9 @@ export type { DownloadStartedEvent, } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader"; - +// Glass Poster (tvOS 26+) +export type { GlassPosterViewProps } from "./glass-poster"; +export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; // MPV Player (iOS + Android) export type { AudioTrack as MpvAudioTrack, From 4606b9718ee56200f44b0ffa24116132ca59a52f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 20:18:12 +0100 Subject: [PATCH 104/309] feat(tv): swap layout and add horizontal posters for episodes --- components/ItemContent.tv.tsx | 82 ++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 999d6037b..c8cde42c5 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -386,6 +386,17 @@ export const ItemContentTV: React.FC = React.memo( return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); }, [api, item?.Type, item?.SeasonId, item?.ParentId]); + // Episode thumbnail URL - 16:9 horizontal image for episode items + const episodeThumbnailUrl = useMemo(() => { + if (item?.Type !== "Episode" || !api) return null; + // Use parent backdrop thumb if available (series/season thumbnail) + if (item.ParentBackdropItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; + } + // Fall back to episode's primary image (which is usually 16:9 for episodes) + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; + }, [api, item]); + // Determine which option button is the last one (for focus guide targeting) const lastOptionButton = useMemo(() => { const hasSubtitleOption = @@ -456,36 +467,7 @@ export const ItemContentTV: React.FC = React.memo( minHeight: SCREEN_HEIGHT * 0.45, }} > - {/* Left side - Poster */} - - - - - - - {/* Right side - Content */} + {/* Left side - Content */} {/* Logo or Title */} {logoUrl ? ( @@ -733,6 +715,46 @@ export const ItemContentTV: React.FC = React.memo( /> )} + + {/* Right side - Poster */} + + + {item.Type === "Episode" && episodeThumbnailUrl ? ( + + ) : ( + + )} + + {/* Additional info section */} From d5f7a18fe5c154dfaaec466944bbfea90733552f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 20:19:45 +0100 Subject: [PATCH 105/309] chore: docs --- .claude/learned-facts.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 86183d472..964bd9e51 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -32,4 +32,12 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_ -- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_ \ No newline at end of file +- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_ + +- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_ + +- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_ + +- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_ + +- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ \ No newline at end of file From 2c9906377dd1e654618085915d3851bfa9a363ea Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 21:31:37 +0100 Subject: [PATCH 106/309] feat(tv): update skeleton layout to match swapped poster position --- components/ItemContentSkeleton.tv.tsx | 49 ++++++++++++++------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx index 6b1069378..e81864344 100644 --- a/components/ItemContentSkeleton.tv.tsx +++ b/components/ItemContentSkeleton.tv.tsx @@ -1,41 +1,28 @@ import React from "react"; import { Dimensions, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); export const ItemContentSkeletonTV: React.FC = () => { + const insets = useSafeAreaInsets(); + return ( - {/* Left side - Poster placeholder */} - + {/* Left side - Content placeholders */} + + {/* Logo placeholder */} - - - {/* Right side - Content placeholders */} - - {/* Logo/Title placeholder */} - { }} /> + + {/* Right side - Poster placeholder */} + + + ); }; From 0c6c20f563556c8e9352af3ae938585623f8baa4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 22:53:24 +0100 Subject: [PATCH 107/309] feat(tv): add horizontal gradient fade to hero carousel backdrop --- components/home/TVHeroCarousel.tsx | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 72142a185..44efc01cc 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -23,7 +23,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { GlassPosterView, @@ -188,6 +188,7 @@ export const TVHeroCarousel: React.FC = ({ items, onItemFocus, }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -428,6 +429,20 @@ export const TVHeroCarousel: React.FC = ({ height: "40%", }} /> + {/* Horizontal gradient for left side text contrast */} + {/* Content overlay */} @@ -454,7 +469,7 @@ export const TVHeroCarousel: React.FC = ({ ) : ( = ({ {episodeSubtitle && ( = ({ {activeItem?.Overview && ( @@ -507,7 +522,7 @@ export const TVHeroCarousel: React.FC = ({ {year && ( @@ -517,7 +532,7 @@ export const TVHeroCarousel: React.FC = ({ {duration && ( @@ -536,7 +551,7 @@ export const TVHeroCarousel: React.FC = ({ > @@ -572,7 +587,7 @@ export const TVHeroCarousel: React.FC = ({ From 875a017e8cf09f9baa90458e93efad528b4592fd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 22:55:44 +0100 Subject: [PATCH 108/309] feat(tv): add scalable typography with user-configurable text size --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 52 ++++++- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 4 +- .../(tabs)/(watchlists)/[watchlistId].tsx | 34 ++--- app/(auth)/tv-request-modal.tsx | 21 ++- app/(auth)/tv-season-select-modal.tsx | 19 ++- app/(auth)/tv-series-season-modal.tsx | 8 +- components/Badge.tsx | 6 +- components/GenreTags.tsx | 7 +- components/ItemContent.tv.tsx | 13 +- components/home/Favorites.tv.tsx | 7 +- components/home/Home.tv.tsx | 11 +- .../InfiniteScrollingCollectionList.tv.tsx | 33 ++-- .../StreamystatsPromotedWatchlists.tv.tsx | 20 ++- .../home/StreamystatsRecommendations.tv.tsx | 20 ++- .../jellyseerr/discover/TVDiscoverSlide.tsx | 10 +- components/jellyseerr/tv/TVJellyseerrPage.tsx | 28 ++-- components/jellyseerr/tv/TVRequestModal.tsx | 9 +- .../jellyseerr/tv/TVRequestOptionRow.tsx | 7 +- .../jellyseerr/tv/TVToggleOptionRow.tsx | 8 +- components/library/TVLibraries.tsx | 9 +- components/library/TVLibraryCard.tsx | 6 +- .../search/TVJellyseerrSearchResults.tsx | 19 ++- components/search/TVSearchBadge.tsx | 4 +- components/search/TVSearchPage.tsx | 10 +- components/search/TVSearchSection.tsx | 30 ++-- components/search/TVSearchTabBadges.tsx | 4 +- components/series/TVEpisodeCard.tsx | 15 +- components/series/TVSeriesHeader.tsx | 9 +- components/series/TVSeriesPage.tsx | 12 +- components/tv/TVActorCard.tsx | 7 +- components/tv/TVCancelButton.tsx | 5 +- components/tv/TVCastCrewText.tsx | 13 +- components/tv/TVCastSection.tsx | 5 +- components/tv/TVFilterButton.tsx | 7 +- components/tv/TVItemCardText.tsx | 44 +++--- components/tv/TVLanguageCard.tsx | 51 ++++--- components/tv/TVMetadataBadges.tsx | 8 +- components/tv/TVNextEpisodeCountdown.tsx | 116 +++++++------- components/tv/TVOptionButton.tsx | 11 +- components/tv/TVOptionCard.tsx | 7 +- components/tv/TVOptionSelector.tsx | 100 ++++++------ components/tv/TVSeriesNavigation.tsx | 5 +- components/tv/TVSeriesSeasonCard.tsx | 7 +- components/tv/TVSubtitleResultCard.tsx | 143 +++++++++--------- components/tv/TVTabButton.tsx | 5 +- components/tv/TVTechnicalDetails.tsx | 13 +- components/tv/TVTrackCard.tsx | 51 ++++--- components/tv/settings/TVLogoutButton.tsx | 5 +- components/tv/settings/TVSectionHeader.tsx | 38 ++--- .../tv/settings/TVSettingsOptionButton.tsx | 7 +- components/tv/settings/TVSettingsRow.tsx | 7 +- components/tv/settings/TVSettingsStepper.tsx | 7 +- .../tv/settings/TVSettingsTextInput.tsx | 7 +- components/tv/settings/TVSettingsToggle.tsx | 5 +- .../video-player/controls/Controls.tv.tsx | 35 +++-- .../controls/TechnicalInfoOverlay.tsx | 17 ++- constants/TVTypography.ts | 28 ++++ translations/en.json | 7 +- utils/atoms/settings.ts | 10 ++ 59 files changed, 712 insertions(+), 494 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 1463713dc..8110d1d54 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -17,7 +17,11 @@ import { } from "@/components/tv"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; +import { + AudioTranscodeMode, + TVTypographyScale, + useSettings, +} from "@/utils/atoms/settings"; export default function SettingsTV() { const { t } = useTranslation(); @@ -39,6 +43,8 @@ export default function SettingsTV() { settings.subtitleMode || SubtitlePlaybackMode.Default; const currentAlignX = settings.mpvSubtitleAlignX ?? "center"; const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; + const currentTypographyScale = + settings.tvTypographyScale || TVTypographyScale.Default; // Audio transcoding options const audioTranscodeModeOptions: TVOptionItem[] = useMemo( @@ -130,6 +136,33 @@ export default function SettingsTV() { [currentAlignY], ); + // Typography scale options + const typographyScaleOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.appearance.text_size_small"), + value: TVTypographyScale.Small, + selected: currentTypographyScale === TVTypographyScale.Small, + }, + { + label: t("home.settings.appearance.text_size_default"), + value: TVTypographyScale.Default, + selected: currentTypographyScale === TVTypographyScale.Default, + }, + { + label: t("home.settings.appearance.text_size_large"), + value: TVTypographyScale.Large, + selected: currentTypographyScale === TVTypographyScale.Large, + }, + { + label: t("home.settings.appearance.text_size_extra_large"), + value: TVTypographyScale.ExtraLarge, + selected: currentTypographyScale === TVTypographyScale.ExtraLarge, + }, + ], + [t, currentTypographyScale], + ); + // Get display labels for option buttons const audioTranscodeLabel = useMemo(() => { const option = audioTranscodeModeOptions.find((o) => o.selected); @@ -151,6 +184,11 @@ export default function SettingsTV() { return option?.label || "Bottom"; }, [alignYOptions]); + const typographyScaleLabel = useMemo(() => { + const option = typographyScaleOptions.find((o) => o.selected); + return option?.label || t("home.settings.appearance.text_size_default"); + }, [typographyScaleOptions, t]); + return ( @@ -344,6 +382,18 @@ export default function SettingsTV() { {/* Appearance Section */} + + showOptions({ + title: t("home.settings.appearance.text_size"), + options: typographyScaleOptions, + onSelect: (value) => + updateSettings({ tvTypographyScale: value }), + }) + } + /> { }; const { libraryId } = searchParams; + const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { width: screenWidth } = useWindowDimensions(); @@ -947,7 +949,7 @@ const Page = () => { paddingTop: 100, }} > - + {t("library.no_results")} diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 2ee4592c2..056204232 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -29,7 +29,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; import { @@ -46,17 +46,22 @@ import { userAtom } from "@/providers/JellyfinProvider"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( +type Typography = ReturnType; + +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => ( {item.Name} = ({ item }) => ( ); export default function WatchlistDetailScreen() { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); @@ -212,11 +218,11 @@ export default function WatchlistDetailScreen() { )} - + ); }, - [router], + [router, typography], ); const renderItem = useCallback( @@ -356,7 +362,7 @@ export default function WatchlistDetailScreen() { {watchlist.description && ( - + {items?.length ?? 0}{" "} {(items?.length ?? 0) === 1 ? t("watchlists.item") @@ -395,18 +399,14 @@ export default function WatchlistDetailScreen() { size={20} color='#9ca3af' /> - + {watchlist.isPublic ? t("watchlists.public") : t("watchlists.private")} {!isOwner && ( - + {t("watchlists.by_owner")} )} @@ -426,7 +426,7 @@ export default function WatchlistDetailScreen() { - {t("jellyseerr.advanced")} - {modalState.title} + + {t("jellyseerr.advanced")} + + + {modalState.title} + {isDataLoaded && isReady ? ( - + {t("jellyseerr.request_button")} @@ -451,13 +461,11 @@ const styles = StyleSheet.create({ overflow: "visible", }, heading: { - fontSize: TVTypography.heading, fontWeight: "bold", color: "#FFFFFF", marginBottom: 8, }, subtitle: { - fontSize: TVTypography.callout, color: "rgba(255,255,255,0.6)", marginBottom: 24, }, @@ -482,7 +490,6 @@ const styles = StyleSheet.create({ marginTop: 24, }, buttonText: { - fontSize: TVTypography.callout, fontWeight: "bold", color: "#FFFFFF", }, diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx index 09b46cc55..b9285e65f 100644 --- a/app/(auth)/tv-season-select-modal.tsx +++ b/app/(auth)/tv-season-select-modal.tsx @@ -16,7 +16,7 @@ import { import { Text } from "@/components/common/Text"; import { TVButton } from "@/components/tv"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useTVRequestModal } from "@/hooks/useTVRequestModal"; @@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC = ({ }; export default function TVSeasonSelectModalPage() { + const typography = useScaledTVTypography(); const router = useRouter(); const modalState = useAtomValue(tvSeasonSelectModalAtom); const { t } = useTranslation(); @@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() { trapFocusRight style={styles.content} > - {t("jellyseerr.select_seasons")} - {modalState.title} + + {t("jellyseerr.select_seasons")} + + + {modalState.title} + {/* Season cards horizontal scroll */} - + {t("jellyseerr.request_selected")} {selectedSeasons.size > 0 && ` (${selectedSeasons.size})`} @@ -377,13 +384,11 @@ const styles = StyleSheet.create({ overflow: "visible", }, heading: { - fontSize: TVTypography.heading, fontWeight: "bold", color: "#FFFFFF", marginBottom: 8, }, subtitle: { - fontSize: TVTypography.callout, color: "rgba(255,255,255,0.6)", marginBottom: 24, }, @@ -413,7 +418,6 @@ const styles = StyleSheet.create({ flex: 1, }, seasonTitle: { - fontSize: TVTypography.callout, fontWeight: "600", marginBottom: 4, }, @@ -436,7 +440,6 @@ const styles = StyleSheet.create({ marginTop: 24, }, buttonText: { - fontSize: TVTypography.callout, fontWeight: "bold", color: "#FFFFFF", }, diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx index 05b9ca8c5..b1117e6f4 100644 --- a/app/(auth)/tv-series-season-modal.tsx +++ b/app/(auth)/tv-series-season-modal.tsx @@ -12,12 +12,13 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import { TVCancelButton, TVOptionCard } from "@/components/tv"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; import { store } from "@/utils/store"; export default function TVSeriesSeasonModalPage() { + const typography = useScaledTVTypography(); const router = useRouter(); const modalState = useAtomValue(tvSeriesSeasonModalAtom); const { t } = useTranslation(); @@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() { trapFocusRight style={styles.content} > - {t("item_card.select_season")} + + {t("item_card.select_season")} + {isReady && ( = ({ variant = "purple", ...props }) => { + const typography = useScaledTVTypography(); + const content = ( {iconLeft && {iconLeft}} @@ -69,7 +71,7 @@ export const Badge: React.FC = ({ {iconLeft && {iconLeft}} diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index bc83eafaa..29f1cb303 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -10,7 +10,7 @@ import { type ViewProps, } from "react-native"; import { GlassEffectView } from "react-native-glass-effect-view"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { Text } from "./common/Text"; interface TagProps { @@ -25,6 +25,9 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { + // Hook must be called at the top level, before any conditional returns + const typography = useScaledTVTypography(); + if (Platform.OS === "ios" && !Platform.isTV) { return ( @@ -60,7 +63,7 @@ export const Tag: React.FC< backgroundColor: "rgba(0,0,0,0.3)", }} > - + {text} diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index c8cde42c5..9c4dfe0d5 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -37,7 +37,7 @@ import { TVTechnicalDetails, } from "@/components/tv"; import type { Track } from "@/components/video-player/controls/types"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; @@ -69,6 +69,7 @@ interface ItemContentTVProps { // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { + const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); const [_user] = useAtom(userAtom); const isOffline = useOfflineMode(); @@ -484,7 +485,7 @@ export const ItemContentTV: React.FC = React.memo( ) : ( = React.memo( = React.memo( = React.memo( > = React.memo( /> ; export const Favorites = () => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); @@ -148,7 +149,7 @@ export const Favorites = () => { /> { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 772ea0943..1f7ae86bc 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -32,7 +32,7 @@ import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPr import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; import { TVHeroCarousel } from "@/components/home/TVHeroCarousel"; import { Loader } from "@/components/Loader"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; @@ -62,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection; const BACKDROP_DEBOUNCE_MS = 300; export const Home = () => { + const typography = useScaledTVTypography(); const _router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -579,7 +580,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > @@ -632,7 +633,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ef7bcae70..c8f7879e1 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -20,7 +20,7 @@ import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import ContinueWatchingPoster, { @@ -48,22 +48,27 @@ interface Props extends ViewProps { parentId?: string; } +type Typography = ReturnType; + // TV-specific ItemCardText with larger fonts -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Type === "Episode" ? ( <> {item.Name} = ({ item }) => { <> {item.Name} void; onBlur?: () => void; -}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => { + typography: Typography; +}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => { const { t } = useTranslation(); const width = orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; @@ -137,7 +143,7 @@ const TVSeeAllCard: React.FC<{ /> = ({ parentId, ...props }) => { + const typography = useScaledTVTypography(); const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); @@ -343,7 +350,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ > {renderPoster()} - + ); }, @@ -354,6 +361,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress, handleItemFocus, handleItemBlur, + typography, ], ); @@ -365,7 +373,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ {/* Section Header */} = ({ @@ -421,7 +429,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: TVTypography.callout, + fontSize: typography.callout, }} numberOfLines={1} > @@ -478,6 +486,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ disabled={disabled} onFocus={handleSeeAllFocus} onBlur={handleItemBlur} + typography={typography} /> )} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 1bb168a8b..e2c5ddd74 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -16,7 +16,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -26,18 +26,23 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; const ITEM_GAP = 16; const SCALE_PADDING = 20; -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +type Typography = ReturnType; + +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Name} = ({ onItemFocus, ...props }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -142,11 +148,11 @@ const WatchlistSection: React.FC = ({ {item.Type === "Movie" && } {item.Type === "Series" && } - + ); }, - [handleItemPress, onItemFocus], + [handleItemPress, onItemFocus, typography], ); if (!isLoading && (!items || items.length === 0)) return null; @@ -155,7 +161,7 @@ const WatchlistSection: React.FC = ({ ; + interface Props extends ViewProps { title: string; type: "Movie" | "Series"; @@ -34,18 +36,21 @@ interface Props extends ViewProps { onItemFocus?: (item: BaseItemDto) => void; } -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Name} = ({ onItemFocus, ...props }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -203,11 +209,11 @@ export const StreamystatsRecommendations: React.FC = ({ {item.Type === "Movie" && } {item.Type === "Series" && } - + ); }, - [handleItemPress, onItemFocus], + [handleItemPress, onItemFocus, typography], ); if (!streamyStatsEnabled) return null; @@ -218,7 +224,7 @@ export const StreamystatsRecommendations: React.FC = ({ = ({ item, isFirstItem = false, }) => { + const typography = useScaledTVTypography(); const router = useRouter(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC = ({ = ({ {year && ( = ({ slide, isFirstSlide = false, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); @@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC = ({ = ({ onPress, refSetter, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -128,7 +129,7 @@ const TVCastCard: React.FC = ({ = ({ }; export const TVJellyseerrPage: React.FC = () => { + const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => { {/* Title */} { {/* Year */} { > { /> { /> { /> { /> { { /> { /> { = ({ onClose, onRequested, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); @@ -389,7 +390,7 @@ export const TVRequestModal: React.FC = ({ > = ({ = ({ /> = ({ hasTVPreferredFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02, @@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC = ({ > @@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC = ({ = ({ onToggle, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, @@ -57,7 +58,7 @@ const TVToggleChip: React.FC = ({ > = ({ onToggle, disabled = false, }) => { + const typography = useScaledTVTypography(); if (items.length === 0) return null; return ( = ({ library, isFirst, onPress }) => { const [api] = useAtom(apiAtom); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(0.7)).current; @@ -190,7 +192,7 @@ const TVLibraryRow: React.FC<{ { const insets = useSafeAreaInsets(); const router = useRouter(); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const { data: userViews, isLoading: viewsLoading } = useQuery({ queryKey: ["user-views", user?.Id], @@ -360,7 +363,7 @@ export const TVLibraries: React.FC = () => { alignItems: "center", }} > - + {t("library.no_libraries_found")} diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx index 70918762b..ef607b743 100644 --- a/components/library/TVLibraryCard.tsx +++ b/components/library/TVLibraryCard.tsx @@ -12,6 +12,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC = ({ library }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const url = useMemo( () => @@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC = ({ library }) => { = ({ library }) => { {itemsCount !== undefined && ( = ({ onPress, isFirstItem = false, }) => { + const typography = useScaledTVTypography(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC = ({ = ({ {year && ( = ({ item, onPress, }) => { + const typography = useScaledTVTypography(); const { jellyseerrApi } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC = ({ = ({ isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ isFirstSection: _isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ onPress, hasTVPreferredFocus = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); @@ -41,7 +43,7 @@ export const TVSearchBadge: React.FC = ({ > { + const typography = useScaledTVTypography(); const itemWidth = 210; return ( @@ -72,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => { color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: TVTypography.callout, + fontSize: typography.callout, }} numberOfLines={1} > @@ -150,6 +151,7 @@ export const TVSearchPage: React.FC = ({ onJellyseerrPersonPress, discoverSliders, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); @@ -308,7 +310,7 @@ export const TVSearchPage: React.FC = ({ = ({ diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index 695c64ccd..eea3836b9 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -11,27 +11,28 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; const ITEM_GAP = 16; const SCALE_PADDING = 20; // TV-specific ItemCardText with larger fonts const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const typography = useScaledTVTypography(); return ( {item.Type === "Episode" ? ( <> {item.Name} = ({ item }) => { = ({ item }) => { <> {item.Name} = ({ item }) => { <> {item.Name} = ({ item }) => { <> {item.Name} = ({ item }) => { ) : item.Type === "Person" ? ( {item.Name} @@ -119,13 +120,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { <> {item.Name} = ({ imageUrlGetter, ...props }) => { + const typography = useScaledTVTypography(); const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -358,7 +360,7 @@ export const TVSearchSection: React.FC = ({ {/* Section Header */} = ({ hasTVPreferredFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); @@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC = ({ > = ({ onBlur, refSetter, }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const thumbnailUrl = useMemo(() => { @@ -112,7 +113,7 @@ export const TVEpisodeCard: React.FC = ({ {episodeLabel && ( = ({ )} {duration && ( <> - + - + {duration} @@ -138,7 +135,7 @@ export const TVEpisodeCard: React.FC = ({ = ({ item }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const logoUrl = useMemo(() => { @@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => { ) : ( = ({ item }) => { }} > {yearString && ( - + {yearString} )} @@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => { > void; disabled?: boolean; }> = ({ seasonName, onPress, disabled = false }) => { + const typography = useScaledTVTypography(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -190,7 +191,7 @@ const TVSeasonButton: React.FC<{ > = ({ allEpisodes = [], isLoading: _isLoading, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -567,7 +569,7 @@ export const TVSeriesPage: React.FC = ({ /> = ({ = ({ diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index 888da829e..aec682e9e 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -3,7 +3,7 @@ import { Image } from "expo-image"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVActorCardProps { @@ -19,6 +19,7 @@ export interface TVActorCardProps { export const TVActorCard = React.forwardRef( ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef( ( {person.Role && ( = ({ label = "Cancel", disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); @@ -48,7 +49,7 @@ export const TVCancelButton: React.FC = ({ /> = React.memo( ({ director, cast, hideCast = false }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); if (!director && (!cast || cast.length === 0)) { @@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC = React.memo( = React.memo( = React.memo( > {t("item_card.director")} - + {director.Name} @@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC = React.memo( = React.memo( > {t("item_card.cast")} - + {cast.map((c) => c.Name).join(", ")} diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx index 828ca3f6b..f1f1276b5 100644 --- a/components/tv/TVCastSection.tsx +++ b/components/tv/TVCastSection.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, TVFocusGuideView, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVActorCard } from "./TVActorCard"; export interface TVCastSectionProps { @@ -24,6 +24,7 @@ export const TVCastSection: React.FC = React.memo( firstActorRefSetter, upwardFocusDestination, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); if (cast.length === 0) { @@ -34,7 +35,7 @@ export const TVCastSection: React.FC = React.memo( = ({ disabled = false, hasActiveFilter = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 }); @@ -54,7 +55,7 @@ export const TVFilterButton: React.FC = ({ {label ? ( @@ -63,7 +64,7 @@ export const TVFilterButton: React.FC = ({ ) : null} = ({ item }) => ( - - - {item.Name} - - - {item.ProductionYear} - - -); +export const TVItemCardText: React.FC = ({ item }) => { + const typography = useScaledTVTypography(); + + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx index 24e3ec845..7b4b712c6 100644 --- a/components/tv/TVLanguageCard.tsx +++ b/components/tv/TVLanguageCard.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVLanguageCardProps { @@ -15,6 +15,8 @@ export interface TVLanguageCardProps { export const TVLanguageCard = React.forwardRef( ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef( }, ); -const styles = StyleSheet.create({ - languageCard: { - width: 120, - height: 60, - borderRadius: 12, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 12, - }, - languageCardText: { - fontSize: TVTypography.callout, - fontWeight: "500", - }, - languageCardCode: { - fontSize: TVTypography.callout, - marginTop: 2, - }, - checkmark: { - position: "absolute", - top: 8, - right: 8, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + languageCard: { + width: 120, + height: 60, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + languageCardText: { + fontSize: typography.callout, + fontWeight: "500", + }, + languageCardCode: { + fontSize: typography.callout, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, + }); diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx index b2ccab2e0..4698644e4 100644 --- a/components/tv/TVMetadataBadges.tsx +++ b/components/tv/TVMetadataBadges.tsx @@ -3,7 +3,7 @@ import React from "react"; import { View } from "react-native"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; export interface TVMetadataBadgesProps { year?: number | null; @@ -14,6 +14,8 @@ export interface TVMetadataBadgesProps { export const TVMetadataBadges: React.FC = React.memo( ({ year, duration, officialRating, communityRating }) => { + const typography = useScaledTVTypography(); + return ( = React.memo( }} > {year != null && ( - + {year} )} {duration && ( - + {duration} )} diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 2030d109d..ef1ea6cc3 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -1,7 +1,7 @@ import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; -import { type FC, useEffect, useRef } from "react"; +import { type FC, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Image, StyleSheet, View } from "react-native"; import Animated, { @@ -13,7 +13,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; export interface TVNextEpisodeCountdownProps { @@ -31,6 +31,7 @@ export const TVNextEpisodeCountdown: FC = ({ isPlaying, onFinish, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const progress = useSharedValue(0); const onFinishRef = useRef(onFinish); @@ -69,6 +70,8 @@ export const TVNextEpisodeCountdown: FC = ({ width: `${progress.value * 100}%`, })); + const styles = useMemo(() => createStyles(typography), [typography]); + if (!show) return null; return ( @@ -105,57 +108,58 @@ export const TVNextEpisodeCountdown: FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - position: "absolute", - bottom: 180, - right: 80, - zIndex: 100, - }, - blur: { - borderRadius: 16, - overflow: "hidden", - }, - innerContainer: { - flexDirection: "row", - alignItems: "stretch", - }, - thumbnail: { - width: 180, - backgroundColor: "rgba(0,0,0,0.3)", - }, - content: { - padding: 16, - justifyContent: "center", - width: 280, - }, - label: { - fontSize: TVTypography.callout, - color: "rgba(255,255,255,0.5)", - textTransform: "uppercase", - letterSpacing: 1, - marginBottom: 4, - }, - seriesName: { - fontSize: TVTypography.callout, - color: "rgba(255,255,255,0.7)", - marginBottom: 2, - }, - episodeInfo: { - fontSize: TVTypography.body, - color: "#fff", - fontWeight: "600", - marginBottom: 12, - }, - progressContainer: { - height: 4, - backgroundColor: "rgba(255,255,255,0.2)", - borderRadius: 2, - overflow: "hidden", - }, - progressBar: { - height: "100%", - backgroundColor: "#fff", - borderRadius: 2, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + container: { + position: "absolute", + bottom: 180, + right: 80, + zIndex: 100, + }, + blur: { + borderRadius: 16, + overflow: "hidden", + }, + innerContainer: { + flexDirection: "row", + alignItems: "stretch", + }, + thumbnail: { + width: 180, + backgroundColor: "rgba(0,0,0,0.3)", + }, + content: { + padding: 16, + justifyContent: "center", + width: 280, + }, + label: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: 4, + }, + seriesName: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.7)", + marginBottom: 2, + }, + episodeInfo: { + fontSize: typography.body, + color: "#fff", + fontWeight: "600", + marginBottom: 12, + }, + progressContainer: { + height: 4, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 2, + overflow: "hidden", + }, + progressBar: { + height: "100%", + backgroundColor: "#fff", + borderRadius: 2, + }, + }); diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx index 342caac95..413595618 100644 --- a/components/tv/TVOptionButton.tsx +++ b/components/tv/TVOptionButton.tsx @@ -2,7 +2,7 @@ import { BlurView } from "expo-blur"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVOptionButtonProps { @@ -14,6 +14,7 @@ export interface TVOptionButtonProps { export const TVOptionButton = React.forwardRef( ({ label, value, onPress, hasTVPreferredFocus }, ref) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); @@ -50,7 +51,7 @@ export const TVOptionButton = React.forwardRef( > @@ -58,7 +59,7 @@ export const TVOptionButton = React.forwardRef( ( > @@ -96,7 +97,7 @@ export const TVOptionButton = React.forwardRef( ( }, ref, ) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef( > ( {sublabel && ( ({ cardWidth = 160, cardHeight = 75, }: TVOptionSelectorProps) => { + const typography = useScaledTVTypography(); const [isReady, setIsReady] = useState(false); const firstCardRef = useRef(null); @@ -91,6 +92,8 @@ export const TVOptionSelector = ({ } }, [isReady]); + const styles = useMemo(() => createStyles(typography), [typography]); + if (!visible) return null; return ( @@ -151,50 +154,51 @@ export const TVOptionSelector = ({ ); }; -const styles = StyleSheet.create({ - overlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "flex-end", - zIndex: 1000, - }, - sheetContainer: { - width: "100%", - }, - blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: "hidden", - }, - content: { - paddingTop: 24, - paddingBottom: 50, - overflow: "visible", - }, - title: { - fontSize: TVTypography.callout, - fontWeight: "500", - color: "rgba(255,255,255,0.6)", - marginBottom: 16, - paddingHorizontal: 48, - textTransform: "uppercase", - letterSpacing: 1, - }, - scrollView: { - overflow: "visible", - }, - scrollContent: { - paddingHorizontal: 48, - paddingVertical: 20, - gap: 12, - }, - cancelButtonContainer: { - marginTop: 16, - paddingHorizontal: 48, - alignItems: "flex-start", - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: typography.callout, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 20, + gap: 12, + }, + cancelButtonContainer: { + marginTop: 16, + paddingHorizontal: 48, + alignItems: "flex-start", + }, + }); diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx index 6e0886644..338137753 100644 --- a/components/tv/TVSeriesNavigation.tsx +++ b/components/tv/TVSeriesNavigation.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; export interface TVSeriesNavigationProps { @@ -16,6 +16,7 @@ export interface TVSeriesNavigationProps { export const TVSeriesNavigation: React.FC = React.memo( ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); // Only show for episodes with a series @@ -27,7 +28,7 @@ export const TVSeriesNavigation: React.FC = React.memo( = ({ onPress, hasTVPreferredFocus, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -104,7 +105,7 @@ export const TVSeriesSeasonCard: React.FC = ({ = ({ {subtitle && ( (({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.03 }); @@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef< ); }); -const styles = StyleSheet.create({ - resultCard: { - width: 220, - minHeight: 120, - borderRadius: 14, - padding: 14, - borderWidth: 1, - }, - providerBadge: { - alignSelf: "flex-start", - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 6, - marginBottom: 8, - }, - providerText: { - fontSize: TVTypography.callout, - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0.5, - }, - resultName: { - fontSize: TVTypography.callout, - fontWeight: "500", - marginBottom: 8, - lineHeight: 18, - }, - resultMeta: { - flexDirection: "row", - alignItems: "center", - gap: 12, - marginBottom: 8, - }, - resultMetaText: { - fontSize: TVTypography.callout, - }, - ratingContainer: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - downloadCountContainer: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - flagsContainer: { - flexDirection: "row", - gap: 6, - flexWrap: "wrap", - }, - flag: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - }, - flagText: { - fontSize: TVTypography.callout, - fontWeight: "600", - color: "#fff", - }, - downloadingOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0,0,0,0.5)", - borderRadius: 14, - justifyContent: "center", - alignItems: "center", - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + resultCard: { + width: 220, + minHeight: 120, + borderRadius: 14, + padding: 14, + borderWidth: 1, + }, + providerBadge: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + marginBottom: 8, + }, + providerText: { + fontSize: typography.callout, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + resultName: { + fontSize: typography.callout, + fontWeight: "500", + marginBottom: 8, + lineHeight: 18, + }, + resultMeta: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 8, + }, + resultMetaText: { + fontSize: typography.callout, + }, + ratingContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + downloadCountContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + flagsContainer: { + flexDirection: "row", + gap: 6, + flexWrap: "wrap", + }, + flag: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + flagText: { + fontSize: typography.callout, + fontWeight: "600", + color: "#fff", + }, + downloadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + }, + }); diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx index c421f573e..d545985b6 100644 --- a/components/tv/TVTabButton.tsx +++ b/components/tv/TVTabButton.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Animated, Pressable } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVTabButtonProps { @@ -21,6 +21,7 @@ export const TVTabButton: React.FC = ({ switchOnFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05, @@ -56,7 +57,7 @@ export const TVTabButton: React.FC = ({ > = React.memo( ({ mediaStreams }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const videoStream = mediaStreams.find((s) => s.Type === "Video"); @@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( = React.memo( > Video - + {videoStream.DisplayTitle || `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} @@ -56,7 +57,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( > Audio - + {audioStream.DisplayTitle || `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx index e1b7106f4..7ec27d097 100644 --- a/components/tv/TVTrackCard.tsx +++ b/components/tv/TVTrackCard.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVTrackCardProps { @@ -15,6 +15,8 @@ export interface TVTrackCardProps { export const TVTrackCard = React.forwardRef( ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef( }, ); -const styles = StyleSheet.create({ - trackCard: { - width: 180, - height: 80, - borderRadius: 14, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 12, - }, - trackCardText: { - fontSize: TVTypography.callout, - textAlign: "center", - }, - trackCardSublabel: { - fontSize: TVTypography.callout, - marginTop: 2, - }, - checkmark: { - position: "absolute", - top: 8, - right: 8, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + trackCard: { + width: 180, + height: 80, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + trackCardText: { + fontSize: typography.callout, + textAlign: "center", + }, + trackCardSublabel: { + fontSize: typography.callout, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, + }); diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx index 9df832f10..77a8ddf9c 100644 --- a/components/tv/settings/TVLogoutButton.tsx +++ b/components/tv/settings/TVLogoutButton.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVLogoutButtonProps { @@ -15,6 +15,7 @@ export const TVLogoutButton: React.FC = ({ disabled, }) => { const { t } = useTranslation(); + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC = ({ > = ({ title }) => ( - - {title} - -); +export const TVSectionHeader: React.FC = ({ title }) => { + const typography = useScaledTVTypography(); + + return ( + + {title} + + ); +}; diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx index 52978caa1..07f879ce9 100644 --- a/components/tv/settings/TVSettingsOptionButton.tsx +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVSettingsOptionButtonProps { @@ -20,6 +20,7 @@ export const TVSettingsOptionButton: React.FC = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -49,13 +50,13 @@ export const TVSettingsOptionButton: React.FC = ({ }, ]} > - + {label} = ({ showChevron = true, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC = ({ }, ]} > - + {label} = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 }); const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); @@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC = ({ focusable={!disabled} > - + {label} @@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC = ({ = ({ secureTextEntry, disabled, }) => { + const typography = useScaledTVTypography(); const inputRef = useRef(null); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC = ({ > = ({ autoCapitalize='none' autoCorrect={false} style={{ - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", backgroundColor: "rgba(255, 255, 255, 0.05)", borderRadius: 8, diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx index c50a6518b..3522f711f 100644 --- a/components/tv/settings/TVSettingsToggle.tsx +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVSettingsToggleProps { @@ -19,6 +19,7 @@ export const TVSettingsToggle: React.FC = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC = ({ }, ]} > - + {label} = ({ playMethod, transcodeReasons, }) => { + const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); const { width: screenWidth } = useWindowDimensions(); const { t } = useTranslation(); @@ -973,14 +974,16 @@ export const Controls: FC = ({ - + {formatTimeString(currentTime, "ms")} - + -{formatTimeString(remainingTime, "ms")} - + {t("player.ends_at")} {getFinishTime()} @@ -1006,12 +1009,18 @@ export const Controls: FC = ({ {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} - {item?.Name} + + {item?.Name} + {item?.Type === "Movie" && ( - {item?.ProductionYear} + + {item?.ProductionYear} + )} @@ -1110,14 +1119,16 @@ export const Controls: FC = ({ - + {formatTimeString(currentTime, "ms")} - + -{formatTimeString(remainingTime, "ms")} - + {t("player.ends_at")} {getFinishTime()} @@ -1151,11 +1162,9 @@ const styles = StyleSheet.create({ }, subtitleText: { color: "rgba(255,255,255,0.6)", - fontSize: TVTypography.body, }, titleText: { color: "#fff", - fontSize: TVTypography.heading, fontWeight: "bold", }, controlButtonsRow: { @@ -1218,7 +1227,6 @@ const styles = StyleSheet.create({ }, timeText: { color: "rgba(255,255,255,0.7)", - fontSize: TVTypography.body, }, timeRight: { flexDirection: "column", @@ -1226,7 +1234,6 @@ const styles = StyleSheet.create({ }, endsAtText: { color: "rgba(255,255,255,0.5)", - fontSize: TVTypography.callout, marginTop: 2, }, // Minimal seek bar styles diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index 5d87e6970..ad6dded59 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -15,7 +15,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import type { TechnicalInfo } from "@/modules/mpv-player"; import { useSettings } from "@/utils/atoms/settings"; import { HEADER_LAYOUT } from "./constants"; @@ -183,6 +183,7 @@ export const TechnicalInfoOverlay: FC = memo( currentSubtitleIndex, currentAudioIndex, }) => { + const typography = useScaledTVTypography(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); const [info, setInfo] = useState(null); @@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC = memo( : HEADER_LAYOUT.CONTAINER_PADDING + 20, }; - const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText; - const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText; + const textStyle = Platform.isTV + ? [ + styles.infoTextTV, + { fontSize: typography.body, lineHeight: typography.body * 1.5 }, + ] + : styles.infoText; + const reasonStyle = Platform.isTV + ? [styles.reasonTextTV, { fontSize: typography.callout }] + : styles.reasonText; const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox; return ( @@ -383,9 +391,7 @@ const styles = StyleSheet.create({ }, infoTextTV: { color: "white", - fontSize: TVTypography.body, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", - lineHeight: TVTypography.body * 1.5, }, warningText: { color: "#ff9800", @@ -396,6 +402,5 @@ const styles = StyleSheet.create({ }, reasonTextTV: { color: "#fbbf24", - fontSize: TVTypography.callout, }, }); diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index ec3fb43bb..c00ff16b1 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -1,3 +1,5 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; + /** * TV Typography Scale * @@ -23,3 +25,29 @@ export const TVTypography = { } as const; export type TVTypographyKey = keyof typeof TVTypography; + +const scaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.85, + [TVTypographyScale.Default]: 1.0, + [TVTypographyScale.Large]: 1.15, + [TVTypographyScale.ExtraLarge]: 1.3, +}; + +/** + * Hook that returns scaled TV typography values based on user settings. + * Use this instead of the static TVTypography constant for dynamic scaling. + */ +export const useScaledTVTypography = () => { + const { settings } = useSettings(); + const scale = + scaleMultipliers[settings.tvTypographyScale] ?? + scaleMultipliers[TVTypographyScale.Default]; + + return { + display: Math.round(TVTypography.display * scale), + title: Math.round(TVTypography.title * scale), + heading: Math.round(TVTypography.heading * scale), + body: Math.round(TVTypography.body * scale), + callout: Math.round(TVTypography.callout * scale), + }; +}; diff --git a/translations/en.json b/translations/en.json index 855d1cf61..c435ea304 100644 --- a/translations/en.json +++ b/translations/en.json @@ -126,7 +126,12 @@ "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "hide_remote_session_button": "Hide Remote Session Button", "show_home_backdrop": "Dynamic Home Backdrop", - "show_hero_carousel": "Hero Carousel" + "show_hero_carousel": "Hero Carousel", + "text_size": "Text Size", + "text_size_small": "Small", + "text_size_default": "Default", + "text_size_large": "Large", + "text_size_extra_large": "Extra Large" }, "network": { "title": "Network", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 3038199b4..c3f9ba817 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -134,6 +134,14 @@ export enum VideoPlayer { MPV = 0, } +// TV Typography scale presets +export enum TVTypographyScale { + Small = "small", + Default = "default", + Large = "large", + ExtraLarge = "extraLarge", +} + // Audio transcoding mode - controls how surround audio is handled // This controls server-side transcoding behavior for audio streams. // MPV decodes via FFmpeg and supports most formats, but mobile devices @@ -202,6 +210,7 @@ export type Settings = { // TV-specific settings showHomeBackdrop: boolean; showTVHeroCarousel: boolean; + tvTypographyScale: TVTypographyScale; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -291,6 +300,7 @@ export const defaultValues: Settings = { // TV-specific settings showHomeBackdrop: true, showTVHeroCarousel: true, + tvTypographyScale: TVTypographyScale.Default, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, From dca7cc99f271bd7fed652f92d15373a4229b1628 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 23:01:08 +0100 Subject: [PATCH 109/309] feat(tv): add setting to show series poster on episode detail pages --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 15 ++++++++++++--- components/ItemContent.tv.tsx | 22 ++++++++++++++-------- components/home/TVHeroCarousel.tsx | 17 ++++++++++++----- translations/en.json | 1 + utils/atoms/settings.ts | 2 ++ 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 8110d1d54..10ad90290 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -15,6 +15,7 @@ import { TVSettingsTextInput, TVSettingsToggle, } from "@/components/tv"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { @@ -31,6 +32,7 @@ export default function SettingsTV() { const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); const { showOptions } = useTVOptionModal(); + const typography = useScaledTVTypography(); // Local state for OpenSubtitles API key (only commit on blur) const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( @@ -204,7 +206,7 @@ export default function SettingsTV() { {/* Header */} updateSettings({ showTVHeroCarousel: value })} /> + + updateSettings({ showSeriesPosterOnEpisode: value }) + } + /> {/* User Section */} = React.memo( return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); }, [api, item?.Type, item?.SeasonId, item?.ParentId]); - // Episode thumbnail URL - 16:9 horizontal image for episode items + // Episode thumbnail URL - episode's own primary image (16:9 for episodes) const episodeThumbnailUrl = useMemo(() => { if (item?.Type !== "Episode" || !api) return null; - // Use parent backdrop thumb if available (series/season thumbnail) - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; - } - // Fall back to episode's primary image (which is usually 16:9 for episodes) return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; }, [api, item]); + // Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled + const seriesThumbUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId || !api) return null; + return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; + }, [api, item]); + // Determine which option button is the last one (for focus guide targeting) const lastOptionButton = useMemo(() => { const hasSubtitleOption = @@ -738,9 +739,14 @@ export const ItemContentTV: React.FC = React.memo( shadowRadius: 20, }} > - {item.Type === "Episode" && episodeThumbnailUrl ? ( + {item.Type === "Episode" ? ( diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 44efc01cc..2c4836719 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -63,17 +63,24 @@ const HeroCard: React.FC = React.memo( const posterUrl = useMemo(() => { if (!api) return null; - // Try thumb first, then primary + + // For episodes, always use series thumb + if (item.Type === "Episode") { + if (item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + } + if (item.SeriesId) { + return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`; + } + } + + // For non-episodes, use item's own thumb/primary if (item.ImageTags?.Thumb) { return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; } if (item.ImageTags?.Primary) { return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; } - // For episodes, use series thumb - if (item.Type === "Episode" && item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; - } return null; }, [api, item]); diff --git a/translations/en.json b/translations/en.json index c435ea304..53901aa33 100644 --- a/translations/en.json +++ b/translations/en.json @@ -127,6 +127,7 @@ "hide_remote_session_button": "Hide Remote Session Button", "show_home_backdrop": "Dynamic Home Backdrop", "show_hero_carousel": "Hero Carousel", + "show_series_poster_on_episode": "Show Series Poster on Episodes", "text_size": "Text Size", "text_size_small": "Small", "text_size_default": "Default", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index c3f9ba817..aa4ac0c88 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -211,6 +211,7 @@ export type Settings = { showHomeBackdrop: boolean; showTVHeroCarousel: boolean; tvTypographyScale: TVTypographyScale; + showSeriesPosterOnEpisode: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -301,6 +302,7 @@ export const defaultValues: Settings = { showHomeBackdrop: true, showTVHeroCarousel: true, tvTypographyScale: TVTypographyScale.Default, + showSeriesPosterOnEpisode: false, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, From 36d6686258114415369f4bde6ccc929a1bf825f9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 23:02:52 +0100 Subject: [PATCH 110/309] feat(tv): increase typography scale multipliers for better visibility --- constants/TVTypography.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index c00ff16b1..a2ac3b804 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -29,8 +29,8 @@ export type TVTypographyKey = keyof typeof TVTypography; const scaleMultipliers: Record = { [TVTypographyScale.Small]: 0.85, [TVTypographyScale.Default]: 1.0, - [TVTypographyScale.Large]: 1.15, - [TVTypographyScale.ExtraLarge]: 1.3, + [TVTypographyScale.Large]: 1.2, + [TVTypographyScale.ExtraLarge]: 1.4, }; /** From 715764cef8a19906314ebc9d6e13687dd9b409e2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 23:23:03 +0100 Subject: [PATCH 111/309] feat(tv): add season episode list to episode detail page --- components/ItemContent.tv.tsx | 106 +++++++++++++++++++++++++++++++++- translations/en.json | 1 + 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 41cd249b6..d7f951e2f 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -4,8 +4,8 @@ import type { MediaSourceInfo, MediaStream, } from "@jellyfin/sdk/lib/generated-client/models"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQueryClient } from "@tanstack/react-query"; +import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { useAtom } from "jotai"; @@ -22,7 +22,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { GenreTags } from "@/components/GenreTags"; +import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; import { TVBackdrop, TVButton, @@ -71,7 +73,7 @@ export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); - const [_user] = useAtom(userAtom); + const [user] = useAtom(userAtom); const isOffline = useOfflineMode(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -81,6 +83,31 @@ export const ItemContentTV: React.FC = React.memo( const _itemColors = useImageColorsReturn({ item }); + // State for first episode card ref (used for focus guide) + const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); + + // Fetch season episodes for episodes + const { data: seasonEpisodes = [] } = useQuery({ + queryKey: ["episodes", item?.SeasonId], + queryFn: async () => { + if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return []; + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.SeriesId, + userId: user.Id, + seasonId: item.SeasonId, + enableUserData: true, + fields: ["MediaSources", "Overview"], + }); + return res.data.Items || []; + }, + enabled: + !!api && + !!user?.Id && + !!item?.SeriesId && + !!item?.SeasonId && + item?.Type === "Episode", + }); + const [selectedOptions, setSelectedOptions] = useState< SelectedOptions | undefined >(undefined); @@ -440,6 +467,14 @@ export const ItemContentTV: React.FC = React.memo( } }, [router, item?.SeriesId, item?.ParentIndexNumber]); + const handleEpisodePress = useCallback( + (episode: BaseItemDto) => { + const navigation = getItemNavigation(episode, "(home)"); + router.push(navigation as any); + }, + [router], + ); + if (!item || !selectedOptions) return null; return ( @@ -792,6 +827,71 @@ export const ItemContentTV: React.FC = React.memo( /> )} + {/* Focus guide: cast → episodes (downward navigation) */} + {showVisualCast && firstEpisodeRef && ( + + )} + + {/* Season Episodes - Episode only */} + {item.Type === "Episode" && seasonEpisodes.length > 1 && ( + + + {t("item_card.more_from_this_season")} + + + {/* Focus guides - stacked together above the list */} + {/* Downward: options → first episode (only when no cast section) */} + {!showVisualCast && firstEpisodeRef && ( + + )} + {/* Upward: episodes → cast (first actor) or options (last button) */} + {(firstActorCardRef || lastOptionButtonRef) && ( + + )} + + + {seasonEpisodes.map((episode, index) => ( + handleEpisodePress(episode)} + disabled={episode.Id === item.Id} + refSetter={index === 0 ? setFirstEpisodeRef : undefined} + /> + ))} + + + )} + {/* From this Series - Episode only */} Date: Mon, 26 Jan 2026 07:51:55 +0100 Subject: [PATCH 112/309] style(tv): update episode section heading typography and spacing --- components/series/TVSeriesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 6583731ef..64830da53 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -593,10 +593,10 @@ export const TVSeriesPage: React.FC = ({ From 92c70fadd1860490ebad6685fc47633851f59275 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 08:16:59 +0100 Subject: [PATCH 113/309] refactor(tv): reorganize item detail page layout and improve episode list --- components/ItemContent.tv.tsx | 98 +++++++++-------------------- components/series/TVEpisodeCard.tsx | 8 +-- 2 files changed, 34 insertions(+), 72 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index d7f951e2f..4f467b9a3 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -17,7 +17,7 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native"; +import { Dimensions, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; @@ -84,7 +84,7 @@ export const ItemContentTV: React.FC = React.memo( const _itemColors = useImageColorsReturn({ item }); // State for first episode card ref (used for focus guide) - const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); + const [_firstEpisodeRef, setFirstEpisodeRef] = useState(null); // Fetch season episodes for episodes const { data: seasonEpisodes = [] } = useQuery({ @@ -163,14 +163,13 @@ export const ItemContentTV: React.FC = React.memo( const { showSubtitleModal } = useTVSubtitleModal(); // State for first actor card ref (used for focus guide) - const [firstActorCardRef, setFirstActorCardRef] = useState( + const [_firstActorCardRef, setFirstActorCardRef] = useState( null, ); // State for last option button ref (used for upward focus guide from cast) - const [lastOptionButtonRef, setLastOptionButtonRef] = useState( - null, - ); + const [_lastOptionButtonRef, setLastOptionButtonRef] = + useState(null); // Get available audio tracks const audioTracks = useMemo(() => { @@ -733,14 +732,6 @@ export const ItemContentTV: React.FC = React.memo( )} - {/* Focus guide to direct navigation from options to cast list */} - {fullCast.length > 0 && firstActorCardRef && ( - - )} - {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( = React.memo( {/* Additional info section */} - {/* Cast & Crew (text version) */} - - - {/* Technical details */} - {selectedOptions.mediaSource?.MediaStreams && - selectedOptions.mediaSource.MediaStreams.length > 0 && ( - - )} - - {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} - {showVisualCast && ( - - )} - - {/* Focus guide: cast → episodes (downward navigation) */} - {showVisualCast && firstEpisodeRef && ( - - )} - {/* Season Episodes - Episode only */} {item.Type === "Episode" && seasonEpisodes.length > 1 && ( @@ -849,26 +806,6 @@ export const ItemContentTV: React.FC = React.memo( {t("item_card.more_from_this_season")} - {/* Focus guides - stacked together above the list */} - {/* Downward: options → first episode (only when no cast section) */} - {!showVisualCast && firstEpisodeRef && ( - - )} - {/* Upward: episodes → cast (first actor) or options (last button) */} - {(firstActorCardRef || lastOptionButtonRef) && ( - - )} - = React.memo( onSeriesPress={handleSeriesPress} onSeasonPress={handleSeasonPress} /> + + {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} + {showVisualCast && ( + + )} + + {/* Cast & Crew (text version - director, etc.) */} + + + {/* Technical details */} + {selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + + )} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index c0b923cef..a57391515 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -68,7 +68,7 @@ export const TVEpisodeCard: React.FC = ({ }, [episode.ParentIndexNumber, episode.IndexNumber]); return ( - + = ({ @@ -123,10 +123,10 @@ export const TVEpisodeCard: React.FC = ({ )} {duration && ( <> - + - + {duration} From 44caf4b1ff7b5e89ff4080ab1820a9d2f8ad7327 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 08:17:00 +0100 Subject: [PATCH 114/309] feat(i18n): add swedish translations for tv interface and fix hardcoded strings --- components/tv/TVTechnicalDetails.tsx | 4 ++-- translations/sv.json | 31 ++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/components/tv/TVTechnicalDetails.tsx b/components/tv/TVTechnicalDetails.tsx index 6184c72e7..412759f09 100644 --- a/components/tv/TVTechnicalDetails.tsx +++ b/components/tv/TVTechnicalDetails.tsx @@ -45,7 +45,7 @@ export const TVTechnicalDetails: React.FC = React.memo( marginBottom: 4, }} > - Video + {t("common.video")} {videoStream.DisplayTitle || @@ -64,7 +64,7 @@ export const TVTechnicalDetails: React.FC = React.memo( marginBottom: 4, }} > - Audio + {t("common.audio")} {audioStream.DisplayTitle || diff --git a/translations/sv.json b/translations/sv.json index 483be9716..8788e617b 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -122,7 +122,15 @@ "appearance": { "title": "Utseende", "merge_next_up_continue_watching": "Slå ihop Fortsätt titta och nästa avsnitt", - "hide_remote_session_button": "Dölj fjärrsessionsknapp" + "hide_remote_session_button": "Dölj fjärrsessionsknapp", + "show_home_backdrop": "Dynamisk hembakgrund", + "show_hero_carousel": "Hjältekarusell", + "show_series_poster_on_episode": "Visa serieaffisch på avsnitt", + "text_size": "Textstorlek", + "text_size_small": "Liten", + "text_size_default": "Standard", + "text_size_large": "Stor", + "text_size_extra_large": "Extra stor" }, "network": { "title": "Nätverk", @@ -508,7 +516,10 @@ "next": "Nästa", "back": "Tillbaka", "continue": "Fortsätt", - "verifying": "Verifierar..." + "verifying": "Verifierar...", + "login": "Logga in", + "refresh": "Uppdatera", + "seeAll": "Visa alla" }, "search": { "search": "Sök...", @@ -621,7 +632,9 @@ "search_failed": "Sökningen misslyckades", "no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern", "no_subtitles_found": "Inga undertexter hittades", - "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv" + "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv", + "ends_at": "slutar", + "settings": "Inställningar" }, "item_card": { "next_up": "Näst på tur", @@ -631,8 +644,10 @@ "seasons": "Säsonger", "season": "Säsong ", "from_this_series": "Från den här serien", + "more_from_this_season": "Från denna säsong", "view_series": "Visa serien", "view_season": "Visa säsongen", + "select_season": "Välj säsong", "no_episodes_for_this_season": "Inga avsnitt för den här säsongen", "overview": "Översikt", "more_with": "Mer med {{name}}", @@ -650,7 +665,14 @@ }, "show_more": "Visa Mer", "show_less": "Visa Mindre", + "left": "kvar", + "more_info": "Mer info", + "director": "Regissör", + "cast": "Skådespelare", + "technical_details": "Tekniska detaljer", "appeared_in": "Förekommer I", + "movies": "Filmer", + "shows": "Serier", "could_not_load_item": "Kunde Inte Ladda Artikeln", "none": "Inget", "download": { @@ -738,7 +760,8 @@ "search": "Sök", "library": "Bibliotek", "custom_links": "Egna länkar", - "favorites": "Favoriter" + "favorites": "Favoriter", + "settings": "Inställningar" }, "music": { "title": "Musik", From bbd78542879a5051c064650487679e568fee5300 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 18:03:18 +0100 Subject: [PATCH 115/309] fix(tv): resolve home sections not rendering when hero carousel is enabled --- components/home/Home.tv.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 1f7ae86bc..1b9ee9ddf 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -537,11 +537,22 @@ export const Home = () => { const sections = settings?.home?.sections ? customSections : defaultSections; + // Determine if hero should be shown (separate setting from backdrop) + // We need this early to calculate which sections will actually be rendered + const showHero = useMemo(() => { + return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; + }, [heroItems, settings.showTVHeroCarousel]); + + // Get sections that will actually be rendered (accounting for hero slicing) + const renderedSections = useMemo(() => { + return showHero ? sections.slice(1) : sections; + }, [sections, showHero]); + const highPrioritySectionKeys = useMemo(() => { - return sections + return renderedSections .filter((s) => s.priority === 1) .map((s) => s.queryKey.join("-")); - }, [sections]); + }, [renderedSections]); const allHighPriorityLoaded = useMemo(() => { return highPrioritySectionKeys.every((key) => loadedSections.has(key)); @@ -661,10 +672,6 @@ export const Home = () => { ); - // Determine if hero should be shown (separate setting from backdrop) - const showHero = - heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; - return ( {/* Dynamic backdrop with crossfade - only shown when hero is disabled */} @@ -737,7 +744,7 @@ export const Home = () => { }} > {/* Hero Carousel - Apple TV+ style featured content */} - {showHero && ( + {showHero && heroItems && ( )} @@ -749,16 +756,14 @@ export const Home = () => { }} > {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */} - {sections.slice(showHero ? 1 : 0).map((section, index) => { + {renderedSections.map((section, index) => { // Render Streamystats sections after Recently Added sections // For default sections: place after Recently Added, before Suggested Movies (if present) // For custom sections: place at the very end const hasSuggestedMovies = !settings?.streamyStatsMovieRecommendations && !settings?.home?.sections; - // Adjust index calculation to account for sliced array when hero is shown - const displayedSectionsLength = - sections.length - (showHero ? 1 : 0); + const displayedSectionsLength = renderedSections.length; const streamystatsIndex = displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0); const hasStreamystatsContent = From d51cf47eb4ddd4212364efcdca292c91cf783bd0 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 18:04:22 +0100 Subject: [PATCH 116/309] feat(tv): add scalable poster sizes synchronized with typography settings --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 14 ++--- .../collections/[collectionId].tsx | 10 ++-- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 8 +-- .../(tabs)/(watchlists)/[watchlistId].tsx | 8 +-- components/ContinueWatchingPoster.tv.tsx | 12 ++-- .../InfiniteScrollingCollectionList.tv.tsx | 28 ++++++--- .../StreamystatsPromotedWatchlists.tv.tsx | 21 +++---- .../home/StreamystatsRecommendations.tv.tsx | 16 +++--- components/home/TVHeroCarousel.tsx | 15 +++-- components/persons/TVActorPage.tsx | 12 ++-- components/posters/MoviePoster.tv.tsx | 14 ++--- components/posters/SeriesPoster.tv.tsx | 16 +++--- components/search/TVSearchSection.tsx | 16 +++--- components/series/TVEpisodeCard.tsx | 8 +-- components/tv/TVFocusablePoster.tsx | 5 +- constants/TVPosterSizes.ts | 57 +++++++++++++++++++ translations/en.json | 10 ++-- translations/sv.json | 10 ++-- 18 files changed, 176 insertions(+), 104 deletions(-) create mode 100644 constants/TVPosterSizes.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 10ad90290..5f2afc8f6 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -142,22 +142,22 @@ export default function SettingsTV() { const typographyScaleOptions: TVOptionItem[] = useMemo( () => [ { - label: t("home.settings.appearance.text_size_small"), + label: t("home.settings.appearance.display_size_small"), value: TVTypographyScale.Small, selected: currentTypographyScale === TVTypographyScale.Small, }, { - label: t("home.settings.appearance.text_size_default"), + label: t("home.settings.appearance.display_size_default"), value: TVTypographyScale.Default, selected: currentTypographyScale === TVTypographyScale.Default, }, { - label: t("home.settings.appearance.text_size_large"), + label: t("home.settings.appearance.display_size_large"), value: TVTypographyScale.Large, selected: currentTypographyScale === TVTypographyScale.Large, }, { - label: t("home.settings.appearance.text_size_extra_large"), + label: t("home.settings.appearance.display_size_extra_large"), value: TVTypographyScale.ExtraLarge, selected: currentTypographyScale === TVTypographyScale.ExtraLarge, }, @@ -188,7 +188,7 @@ export default function SettingsTV() { const typographyScaleLabel = useMemo(() => { const option = typographyScaleOptions.find((o) => o.selected); - return option?.label || t("home.settings.appearance.text_size_default"); + return option?.label || t("home.settings.appearance.display_size_default"); }, [typographyScaleOptions, t]); return ( @@ -385,11 +385,11 @@ export default function SettingsTV() { {/* Appearance Section */} showOptions({ - title: t("home.settings.appearance.text_size"), + title: t("home.settings.appearance.display_size"), options: typographyScaleOptions, onSelect: (value) => updateSettings({ tvTypographyScale: value }), diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index aca3b4521..1a6ca0b7c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -27,15 +27,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster, { - TV_POSTER_WIDTH, -} from "@/components/posters/MoviePoster.tv"; +import MoviePoster from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFilterButton, TVFocusablePoster, TVItemCardText, } from "@/components/tv"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; @@ -60,6 +59,7 @@ const page: React.FC = () => { const searchParams = useLocalSearchParams(); const { collectionId } = searchParams as { collectionId: string }; + const posterSizes = useScaledTVPosterSizes(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const navigation = useNavigation(); @@ -153,7 +153,7 @@ const page: React.FC = () => { // Calculate columns for TV grid const nrOfCols = useMemo(() => { if (Platform.isTV) { - const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; + const itemWidth = posterSizes.poster + TV_ITEM_GAP; return Math.max( 1, Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), @@ -291,7 +291,7 @@ const page: React.FC = () => { style={{ marginRight: TV_ITEM_GAP, marginBottom: TV_ITEM_GAP, - width: TV_POSTER_WIDTH, + width: posterSizes.poster, }} > diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 7a3632681..02bc671ef 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -33,15 +33,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster, { - TV_POSTER_WIDTH, -} from "@/components/posters/MoviePoster.tv"; +import MoviePoster from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFilterButton, TVFocusablePoster, TVItemCardText, } from "@/components/tv"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; @@ -85,6 +84,7 @@ const Page = () => { const { libraryId } = searchParams; const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { width: screenWidth } = useWindowDimensions(); @@ -409,7 +409,7 @@ const Page = () => { diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 056204232..0adee9733 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -24,11 +24,10 @@ import { } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster, { - TV_POSTER_WIDTH, -} from "@/components/posters/MoviePoster.tv"; +import MoviePoster from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; @@ -73,6 +72,7 @@ const TVItemCardText: React.FC<{ export default function WatchlistDetailScreen() { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); @@ -206,7 +206,7 @@ export default function WatchlistDetailScreen() { = ({ showPlayButton = false, }) => { const api = useAtomValue(apiAtom); + const posterSizes = useScaledTVPosterSizes(); const url = useMemo(() => { if (!api) { @@ -91,7 +91,7 @@ const ContinueWatchingPoster: React.FC = ({ return ( = ({ progress={progress} showWatchedIndicator={isWatched} isFocused={false} - width={TV_LANDSCAPE_WIDTH} - style={{ width: TV_LANDSCAPE_WIDTH }} + width={posterSizes.landscape} + style={{ width: posterSizes.landscape }} /> {showPlayButton && ( = ({ ; + // TV-specific "See All" card for end of lists const TVSeeAllCard: React.FC<{ onPress: () => void; @@ -109,10 +108,19 @@ const TVSeeAllCard: React.FC<{ onFocus?: () => void; onBlur?: () => void; typography: Typography; -}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => { + posterSizes: PosterSizes; +}> = ({ + onPress, + orientation, + disabled, + onFocus, + onBlur, + typography, + posterSizes, +}) => { const { t } = useTranslation(); const width = - orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; + orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; return ( @@ -172,6 +180,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ ...props }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); @@ -250,7 +259,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }, [data]); const itemWidth = - orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; + orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; const handleItemPress = useCallback( (item: BaseItemDto) => { @@ -487,6 +496,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onFocus={handleSeeAllFocus} onBlur={handleItemBlur} typography={typography} + posterSizes={posterSizes} /> )} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index e2c5ddd74..1c5e69a46 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -11,11 +11,10 @@ import { FlatList, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster, { - TV_POSTER_WIDTH, -} from "@/components/posters/MoviePoster.tv"; +import MoviePoster from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -66,6 +65,7 @@ const WatchlistSection: React.FC = ({ ...props }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -129,8 +129,8 @@ const WatchlistSection: React.FC = ({ const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: TV_POSTER_WIDTH + ITEM_GAP, - offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + length: posterSizes.poster + ITEM_GAP, + offset: (posterSizes.poster + ITEM_GAP) * index, index, }), [], @@ -139,7 +139,7 @@ const WatchlistSection: React.FC = ({ const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - + handleItemPress(item)} onFocus={() => onItemFocus?.(item)} @@ -182,11 +182,11 @@ const WatchlistSection: React.FC = ({ }} > {[1, 2, 3, 4, 5].map((i) => ( - + = ({ enabled = true, onItemFocus, ...props }) => { + const posterSizes = useScaledTVPosterSizes(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -316,11 +317,11 @@ export const StreamystatsPromotedWatchlists: React.FC< }} > {[1, 2, 3, 4, 5].map((i) => ( - + = ({ ...props }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -190,8 +190,8 @@ export const StreamystatsRecommendations: React.FC = ({ const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: TV_POSTER_WIDTH + ITEM_GAP, - offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + length: posterSizes.poster + ITEM_GAP, + offset: (posterSizes.poster + ITEM_GAP) * index, index, }), [], @@ -200,7 +200,7 @@ export const StreamystatsRecommendations: React.FC = ({ const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - + handleItemPress(item)} onFocus={() => onItemFocus?.(item)} @@ -245,11 +245,11 @@ export const StreamystatsRecommendations: React.FC = ({ }} > {[1, 2, 3, 4, 5].map((i) => ( - + void; onPress: (item: BaseItemDto) => void; } const HeroCard: React.FC = React.memo( - ({ item, isFirst, onFocus, onPress }) => { + ({ item, isFirst, cardWidth, onFocus, onPress }) => { const api = useAtomValue(apiAtom); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -129,8 +130,8 @@ const HeroCard: React.FC = React.memo( progress={progress} showWatchedIndicator={false} isFocused={focused} - width={CARD_WIDTH} - style={{ width: CARD_WIDTH }} + width={cardWidth} + style={{ width: cardWidth }} /> ); @@ -147,7 +148,7 @@ const HeroCard: React.FC = React.memo( > = ({ onItemFocus, }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -354,11 +356,12 @@ export const TVHeroCarousel: React.FC = ({ ), - [handleCardFocus, handleCardPress], + [handleCardFocus, handleCardPress, posterSizes.heroCard], ); // Memoize keyExtractor diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index b731ab98c..4f543be83 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -28,9 +28,8 @@ import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; -import MoviePoster, { - TV_POSTER_WIDTH, -} from "@/components/posters/MoviePoster.tv"; +import MoviePoster from "@/components/posters/MoviePoster.tv"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -103,6 +102,7 @@ export const TVActorPage: React.FC = ({ personId }) => { const router = useRouter(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; + const posterSizes = useScaledTVPosterSizes(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -276,8 +276,8 @@ export const TVActorPage: React.FC = ({ personId }) => { // List item layout const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: TV_POSTER_WIDTH + ITEM_GAP, - offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + length: posterSizes.poster + ITEM_GAP, + offset: (posterSizes.poster + ITEM_GAP) * index, index, }), [], @@ -297,7 +297,7 @@ export const TVActorPage: React.FC = ({ personId }) => { > - + diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx index 9d9bdcfa0..ed0475432 100644 --- a/components/posters/MoviePoster.tv.tsx +++ b/components/posters/MoviePoster.tv.tsx @@ -4,6 +4,7 @@ import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { GlassPosterView, isGlassEffectAvailable, @@ -11,8 +12,6 @@ import { import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 260; - type MoviePosterProps = { item: BaseItemDto; showProgress?: boolean; @@ -23,14 +22,15 @@ const MoviePoster: React.FC = ({ showProgress = false, }) => { const [api] = useAtom(apiAtom); + const posterSizes = useScaledTVPosterSizes(); const url = useMemo(() => { return getPrimaryImageUrl({ api, item, - width: 520, // 2x for quality on large screens + width: posterSizes.poster * 2, // 2x for quality on large screens }); - }, [api, item]); + }, [api, item, posterSizes.poster]); const progress = item.UserData?.PlayedPercentage || 0; const isWatched = item.UserData?.Played === true; @@ -52,8 +52,8 @@ const MoviePoster: React.FC = ({ progress={showProgress ? progress : 0} showWatchedIndicator={isWatched} isFocused={false} - width={TV_POSTER_WIDTH} - style={{ width: TV_POSTER_WIDTH }} + width={posterSizes.poster} + style={{ width: posterSizes.poster }} /> ); } @@ -65,7 +65,7 @@ const MoviePoster: React.FC = ({ position: "relative", borderRadius: 24, overflow: "hidden", - width: TV_POSTER_WIDTH, + width: posterSizes.poster, aspectRatio: 10 / 15, }} > diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx index 790718681..125d9d3eb 100644 --- a/components/posters/SeriesPoster.tv.tsx +++ b/components/posters/SeriesPoster.tv.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { GlassPosterView, isGlassEffectAvailable, @@ -10,8 +11,6 @@ import { import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 260; - type SeriesPosterProps = { item: BaseItemDto; showProgress?: boolean; @@ -19,17 +18,18 @@ type SeriesPosterProps = { const SeriesPoster: React.FC = ({ item }) => { const [api] = useAtom(apiAtom); + const posterSizes = useScaledTVPosterSizes(); const url = useMemo(() => { if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`; + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`; } return getPrimaryImageUrl({ api, item, - width: 520, // 2x for quality on large screens + width: posterSizes.poster * 2, // 2x for quality on large screens }); - }, [api, item]); + }, [api, item, posterSizes.poster]); const blurhash = useMemo(() => { const key = item.ImageTags?.Primary as string; @@ -48,8 +48,8 @@ const SeriesPoster: React.FC = ({ item }) => { progress={0} showWatchedIndicator={false} isFocused={false} - width={TV_POSTER_WIDTH} - style={{ width: TV_POSTER_WIDTH }} + width={posterSizes.poster} + style={{ width: posterSizes.poster }} /> ); } @@ -58,7 +58,7 @@ const SeriesPoster: React.FC = ({ item }) => { return ( = ({ ...props }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -181,7 +179,7 @@ export const TVSearchSection: React.FC = ({ }, []); const itemWidth = - orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; + orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ @@ -249,8 +247,8 @@ export const TVSearchSection: React.FC = ({ return ( = ({ refSetter, }) => { const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const api = useAtomValue(apiAtom); const thumbnailUrl = useMemo(() => { @@ -68,7 +68,7 @@ export const TVEpisodeCard: React.FC = ({ }, [episode.ParentIndexNumber, episode.IndexNumber]); return ( - + = ({ > void; onBlur?: () => void; disabled?: boolean; + /** When true, the item remains focusable even when disabled (for navigation purposes) */ + focusableWhenDisabled?: boolean; /** Setter function for the ref (for focus guide destinations) */ refSetter?: (ref: View | null) => void; } @@ -31,6 +33,7 @@ export const TVFocusablePoster: React.FC = ({ onFocus: onFocusProp, onBlur: onBlurProp, disabled = false, + focusableWhenDisabled = false, refSetter, }) => { const [focused, setFocused] = useState(false); @@ -62,7 +65,7 @@ export const TVFocusablePoster: React.FC = ({ }} hasTVPreferredFocus={hasTVPreferredFocus && !disabled} disabled={disabled} - focusable={!disabled} + focusable={!disabled || focusableWhenDisabled} > = { + [TVTypographyScale.Small]: 0.95, + [TVTypographyScale.Default]: 1.0, + [TVTypographyScale.Large]: 1.05, + [TVTypographyScale.ExtraLarge]: 1.1, +}; + +/** + * Hook that returns scaled TV poster sizes based on user settings. + * Use this instead of the static TVPosterSizes constant for dynamic scaling. + * + * @example + * const posterSizes = useScaledTVPosterSizes(); + * + */ +export const useScaledTVPosterSizes = () => { + const { settings } = useSettings(); + const scale = + posterScaleMultipliers[settings.tvTypographyScale] ?? + posterScaleMultipliers[TVTypographyScale.Default]; + + return { + poster: Math.round(TVPosterSizes.poster * scale), + landscape: Math.round(TVPosterSizes.landscape * scale), + episode: Math.round(TVPosterSizes.episode * scale), + heroCard: Math.round(TVPosterSizes.heroCard * scale), + }; +}; diff --git a/translations/en.json b/translations/en.json index e015bec56..05663b326 100644 --- a/translations/en.json +++ b/translations/en.json @@ -128,11 +128,11 @@ "show_home_backdrop": "Dynamic Home Backdrop", "show_hero_carousel": "Hero Carousel", "show_series_poster_on_episode": "Show Series Poster on Episodes", - "text_size": "Text Size", - "text_size_small": "Small", - "text_size_default": "Default", - "text_size_large": "Large", - "text_size_extra_large": "Extra Large" + "display_size": "Display Size", + "display_size_small": "Small", + "display_size_default": "Default", + "display_size_large": "Large", + "display_size_extra_large": "Extra Large" }, "network": { "title": "Network", diff --git a/translations/sv.json b/translations/sv.json index 8788e617b..a9d52daf0 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -126,11 +126,11 @@ "show_home_backdrop": "Dynamisk hembakgrund", "show_hero_carousel": "Hjältekarusell", "show_series_poster_on_episode": "Visa serieaffisch på avsnitt", - "text_size": "Textstorlek", - "text_size_small": "Liten", - "text_size_default": "Standard", - "text_size_large": "Stor", - "text_size_extra_large": "Extra stor" + "display_size": "Visningsstorlek", + "display_size_small": "Liten", + "display_size_default": "Standard", + "display_size_large": "Stor", + "display_size_extra_large": "Extra stor" }, "network": { "title": "Nätverk", From c029228138801c0900dec780da08cfb0f93be8b1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 18:15:52 +0100 Subject: [PATCH 117/309] feat(tv): add now playing badge to current episode in season list --- components/ItemContent.tv.tsx | 23 +++++++++------ components/series/TVEpisodeCard.tsx | 44 ++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 4f467b9a3..7caffb0ba 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -816,15 +816,20 @@ export const ItemContentTV: React.FC = React.memo( gap: 24, }} > - {seasonEpisodes.map((episode, index) => ( - handleEpisodePress(episode)} - disabled={episode.Id === item.Id} - refSetter={index === 0 ? setFirstEpisodeRef : undefined} - /> - ))} + {seasonEpisodes.map((episode, index) => { + const isCurrentEpisode = episode.Id === item.Id; + return ( + handleEpisodePress(episode)} + disabled={isCurrentEpisode} + focusableWhenDisabled={isCurrentEpisode} + isCurrent={isCurrentEpisode} + refSetter={index === 0 ? setFirstEpisodeRef : undefined} + /> + ); + })} )} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 1fb22993a..600393466 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useAtomValue } from "jotai"; @@ -16,6 +17,10 @@ interface TVEpisodeCardProps { episode: BaseItemDto; hasTVPreferredFocus?: boolean; disabled?: boolean; + /** When true, the item remains focusable even when disabled (for navigation purposes) */ + focusableWhenDisabled?: boolean; + /** Shows a "Now Playing" badge on the card */ + isCurrent?: boolean; onPress: () => void; onFocus?: () => void; onBlur?: () => void; @@ -27,6 +32,8 @@ export const TVEpisodeCard: React.FC = ({ episode, hasTVPreferredFocus = false, disabled = false, + focusableWhenDisabled = false, + isCurrent = false, onPress, onFocus, onBlur, @@ -68,11 +75,17 @@ export const TVEpisodeCard: React.FC = ({ }, [episode.ParentIndexNumber, episode.IndexNumber]); return ( - + = ({ )} + + {/* Now Playing badge */} + {isCurrent && ( + + + + Now Playing + + + )} From b79b343ce3582ff326c5f609d3f41af518610176 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 18:36:44 +0100 Subject: [PATCH 118/309] refactor(tv): replace poster multiplier scaling with linear offset-based scaling --- constants/TVPosterSizes.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts index d2f3e32c4..5295adef7 100644 --- a/constants/TVPosterSizes.ts +++ b/constants/TVPosterSizes.ts @@ -9,29 +9,29 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; export const TVPosterSizes = { /** Portrait posters (movies, series) - 10:15 aspect ratio */ - poster: 260, + poster: 256, /** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */ - landscape: 400, + landscape: 396, /** Episode cards - 16:9 aspect ratio */ - episode: 340, + episode: 336, /** Hero carousel cards - 16:9 aspect ratio */ - heroCard: 280, + heroCard: 276, } as const; export type TVPosterSizeKey = keyof typeof TVPosterSizes; /** - * Poster scale multipliers - much smaller range than typography. - * Posters are already near-perfect size, only need slight increases at larger settings. + * Linear poster size offsets (in pixels) - synchronized with typography scale. + * Uses fixed pixel steps for consistent linear scaling across all poster types. */ -const posterScaleMultipliers: Record = { - [TVTypographyScale.Small]: 0.95, - [TVTypographyScale.Default]: 1.0, - [TVTypographyScale.Large]: 1.05, - [TVTypographyScale.ExtraLarge]: 1.1, +const posterScaleOffsets: Record = { + [TVTypographyScale.Small]: -10, + [TVTypographyScale.Default]: 0, + [TVTypographyScale.Large]: 10, + [TVTypographyScale.ExtraLarge]: 20, }; /** @@ -44,14 +44,14 @@ const posterScaleMultipliers: Record = { */ export const useScaledTVPosterSizes = () => { const { settings } = useSettings(); - const scale = - posterScaleMultipliers[settings.tvTypographyScale] ?? - posterScaleMultipliers[TVTypographyScale.Default]; + const offset = + posterScaleOffsets[settings.tvTypographyScale] ?? + posterScaleOffsets[TVTypographyScale.Default]; return { - poster: Math.round(TVPosterSizes.poster * scale), - landscape: Math.round(TVPosterSizes.landscape * scale), - episode: Math.round(TVPosterSizes.episode * scale), - heroCard: Math.round(TVPosterSizes.heroCard * scale), + poster: TVPosterSizes.poster + offset, + landscape: TVPosterSizes.landscape + offset, + episode: TVPosterSizes.episode + offset, + heroCard: TVPosterSizes.heroCard + offset, }; }; From 111397a306164f1d52005f3eb06fdb23948bc929 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:17:29 +0100 Subject: [PATCH 119/309] refactor(tv): extract TVEpisodeList component to reduce code duplication --- components/ItemContent.tv.tsx | 33 ++----- components/series/TVEpisodeCard.tsx | 136 +++++++++++++++++----------- components/series/TVEpisodeList.tsx | 93 +++++++++++++++++++ components/series/TVSeriesPage.tsx | 51 +++-------- 4 files changed, 194 insertions(+), 119 deletions(-) create mode 100644 components/series/TVEpisodeList.tsx diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 7caffb0ba..21738e972 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -24,7 +24,7 @@ import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { GenreTags } from "@/components/GenreTags"; -import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; +import { TVEpisodeList } from "@/components/series/TVEpisodeList"; import { TVBackdrop, TVButton, @@ -806,31 +806,12 @@ export const ItemContentTV: React.FC = React.memo( {t("item_card.more_from_this_season")} - - {seasonEpisodes.map((episode, index) => { - const isCurrentEpisode = episode.Id === item.Id; - return ( - handleEpisodePress(episode)} - disabled={isCurrentEpisode} - focusableWhenDisabled={isCurrentEpisode} - isCurrent={isCurrentEpisode} - refSetter={index === 0 ? setFirstEpisodeRef : undefined} - /> - ); - })} - + )} diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 600393466..262dc3239 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -10,6 +10,10 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { WatchedIndicator } from "@/components/WatchedIndicator"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { + GlassPosterView, + isGlassEffectAvailable, +} from "@/modules/glass-poster"; import { apiAtom } from "@/providers/JellyfinProvider"; import { runtimeTicksToMinutes } from "@/utils/time"; @@ -74,6 +78,42 @@ export const TVEpisodeCard: React.FC = ({ return null; }, [episode.ParentIndexNumber, episode.IndexNumber]); + const progress = episode.UserData?.PlayedPercentage || 0; + const isWatched = episode.UserData?.Played === true; + + // Use glass effect on tvOS 26+ + const useGlass = isGlassEffectAvailable(); + + // Now Playing badge component (shared between glass and fallback) + const NowPlayingBadge = isCurrent ? ( + + + + Now Playing + + + ) : null; + return ( = ({ onBlur={onBlur} refSetter={refSetter} > - - {thumbnailUrl ? ( - + - ) : ( - - )} - - - - {/* Now Playing badge */} - {isCurrent && ( - - - + ) : ( + + {thumbnailUrl ? ( + + ) : ( + - Now Playing - - - )} - + /> + )} + + + {NowPlayingBadge} + + )} {/* Episode info below thumbnail */} diff --git a/components/series/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx new file mode 100644 index 000000000..bb32f7c3e --- /dev/null +++ b/components/series/TVEpisodeList.tsx @@ -0,0 +1,93 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +const LIST_GAP = 24; +const VERTICAL_PADDING = 12; + +interface TVEpisodeListProps { + episodes: BaseItemDto[]; + /** Shows "Now Playing" badge on the episode matching this ID */ + currentEpisodeId?: string; + /** Disable all cards (e.g., when modal is open) */ + disabled?: boolean; + /** Handler when an episode is pressed */ + onEpisodePress: (episode: BaseItemDto) => void; + /** Called when any episode gains focus */ + onFocus?: () => void; + /** Called when any episode loses focus */ + onBlur?: () => void; + /** Ref for programmatic scrolling */ + scrollViewRef?: React.RefObject; + /** Setter for the first episode ref (for focus guide destinations) */ + firstEpisodeRefSetter?: (ref: View | null) => void; + /** Text to show when episodes array is empty */ + emptyText?: string; + /** Horizontal padding for the list content (default: 80) */ + horizontalPadding?: number; +} + +export const TVEpisodeList: React.FC = ({ + episodes, + currentEpisodeId, + disabled = false, + onEpisodePress, + onFocus, + onBlur, + scrollViewRef, + firstEpisodeRefSetter, + emptyText, + horizontalPadding = 80, +}) => { + const typography = useScaledTVTypography(); + + if (episodes.length === 0 && emptyText) { + return ( + + {emptyText} + + ); + } + + return ( + } + horizontal + showsHorizontalScrollIndicator={false} + style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }} + contentContainerStyle={{ + paddingHorizontal: horizontalPadding, + paddingVertical: VERTICAL_PADDING, + gap: LIST_GAP, + }} + > + {episodes.map((episode, index) => { + const isCurrent = currentEpisodeId + ? episode.Id === currentEpisodeId + : false; + return ( + onEpisodePress(episode)} + onFocus={onFocus} + onBlur={onBlur} + disabled={isCurrent || disabled} + focusableWhenDisabled={isCurrent} + isCurrent={isCurrent} + refSetter={index === 0 ? firstEpisodeRefSetter : undefined} + /> + ); + })} + + ); +}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 64830da53..72d26c44a 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -27,7 +27,7 @@ import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { seasonIndexAtom } from "@/components/series/SeasonPicker"; -import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; +import { TVEpisodeList } from "@/components/series/TVEpisodeList"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -46,7 +46,6 @@ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); const HORIZONTAL_PADDING = 80; const TOP_PADDING = 140; const POSTER_WIDTH_PERCENT = 0.22; -const ITEM_GAP = 16; const SCALE_PADDING = 20; interface TVSeriesPageProps { @@ -619,43 +618,17 @@ export const TVSeriesPage: React.FC = ({ /> )} - - {episodesForSeason.length > 0 ? ( - episodesForSeason.map((episode, index) => ( - handleEpisodePress(episode)} - onFocus={handleEpisodeFocus} - onBlur={handleEpisodeBlur} - disabled={isSeasonModalVisible} - // Pass refSetter to first episode for focus guide destination - // Note: Do NOT use hasTVPreferredFocus on focus guide destinations - refSetter={index === 0 ? setFirstEpisodeRef : undefined} - /> - )) - ) : ( - - {t("item_card.no_episodes_for_this_season")} - - )} - + From 7fe24369c0f3f27f2a6de69434bf26661e1119fe Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:32:06 +0100 Subject: [PATCH 120/309] feat(tv): add language-based audio and subtitle track selection --- components/ItemContent.tv.tsx | 12 +- hooks/useDefaultPlaySettings.ts | 14 +- utils/jellyfin/getDefaultPlaySettings.ts | 185 ++++++++++++++++++++++- 3 files changed, 205 insertions(+), 6 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 21738e972..0494f1081 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -112,12 +112,22 @@ export const ItemContentTV: React.FC = React.memo( SelectedOptions | undefined >(undefined); + // Enable language preference application for TV + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); + const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(itemWithSources ?? item, settings); + } = useDefaultPlaySettings( + itemWithSources ?? item, + settings, + playSettingsOptions, + ); const logoUrl = useMemo( () => (item ? getLogoImageUrlById({ api, item }) : null), diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index 255a114da..ad292639a 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -1,19 +1,27 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; import type { Settings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { + getDefaultPlaySettings, + type PlaySettingsOptions, +} from "@/utils/jellyfin/getDefaultPlaySettings"; /** * React hook wrapper for getDefaultPlaySettings. * Used in UI components for initial playback (no previous track state). + * + * @param item - The media item to play + * @param settings - User settings (language preferences, bitrate, etc.) + * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV) */ const useDefaultPlaySettings = ( item: BaseItemDto | null | undefined, settings: Settings | null, + options?: PlaySettingsOptions, ) => useMemo(() => { const { mediaSource, audioIndex, subtitleIndex, bitrate } = - getDefaultPlaySettings(item, settings); + getDefaultPlaySettings(item, settings, undefined, options); return { defaultMediaSource: mediaSource, @@ -21,6 +29,6 @@ const useDefaultPlaySettings = ( defaultSubtitleIndex: subtitleIndex, defaultBitrate: bitrate, }; - }, [item, settings]); + }, [item, settings, options]); export default useDefaultPlaySettings; diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index fba1dbe1c..bfbfb526a 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -12,7 +12,9 @@ import type { BaseItemDto, MediaSourceInfo, + MediaStream, } from "@jellyfin/sdk/lib/generated-client"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { BITRATES } from "@/components/BitrateSelector"; import { type Settings } from "../atoms/settings"; import { @@ -34,17 +36,144 @@ export interface PreviousIndexes { subtitleIndex?: number; } +export interface PlaySettingsOptions { + /** Apply language preferences from settings (used on TV) */ + applyLanguagePreferences?: boolean; +} + +/** + * Find a track by language code. + * + * @param streams - Available media streams + * @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe") + * @param streamType - Type of stream to search ("Audio" or "Subtitle") + * @param forcedOnly - If true, only match forced subtitles + * @returns The stream index if found, undefined otherwise + */ +function findTrackByLanguage( + streams: MediaStream[], + languageCode: string | undefined, + streamType: "Audio" | "Subtitle", + forcedOnly = false, +): number | undefined { + if (!languageCode) return undefined; + + const candidates = streams.filter((s) => { + if (s.Type !== streamType) return false; + if (forcedOnly && !s.IsForced) return false; + // Match on ThreeLetterISOLanguageName (ISO 639-2) + return ( + s.Language?.toLowerCase() === languageCode.toLowerCase() || + // Fallback: some Jellyfin servers use two-letter codes in Language field + s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase() + ); + }); + + // Prefer default track if multiple match + const defaultTrack = candidates.find((s) => s.IsDefault); + return defaultTrack?.Index ?? candidates[0]?.Index; +} + +/** + * Apply subtitle mode logic to determine the final subtitle index. + * + * @param streams - Available media streams + * @param settings - User settings containing subtitleMode + * @param defaultIndex - The current default subtitle index + * @param audioLanguage - The selected audio track's language (for Smart mode) + * @param subtitleLanguageCode - The user's preferred subtitle language + * @returns The final subtitle index (-1 for disabled) + */ +function applySubtitleMode( + streams: MediaStream[], + settings: Settings, + defaultIndex: number, + audioLanguage: string | undefined, + subtitleLanguageCode: string | undefined, +): number { + const subtitleStreams = streams.filter((s) => s.Type === "Subtitle"); + const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default; + + switch (mode) { + case SubtitlePlaybackMode.None: + // Always disable subtitles + return -1; + + case SubtitlePlaybackMode.OnlyForced: { + // Only show forced subtitles, prefer matching language + const forcedMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + true, + ); + if (forcedMatch !== undefined) return forcedMatch; + // Fallback to any forced subtitle + const anyForced = subtitleStreams.find((s) => s.IsForced); + return anyForced?.Index ?? -1; + } + + case SubtitlePlaybackMode.Always: { + // Always enable subtitles, prefer language match + const alwaysMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + if (alwaysMatch !== undefined) return alwaysMatch; + // Fallback to first available or current default + return subtitleStreams[0]?.Index ?? defaultIndex; + } + + case SubtitlePlaybackMode.Smart: { + // Enable subtitles only when audio language differs from subtitle preference + if (audioLanguage && subtitleLanguageCode) { + const audioLang = audioLanguage.toLowerCase(); + const subLang = subtitleLanguageCode.toLowerCase(); + // If audio matches subtitle preference, disable subtitles + if ( + audioLang === subLang || + audioLang.startsWith(subLang.substring(0, 2)) || + subLang.startsWith(audioLang.substring(0, 2)) + ) { + return -1; + } + } + // Audio doesn't match preference, enable subtitles + const smartMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + return smartMatch ?? subtitleStreams[0]?.Index ?? -1; + } + default: + // Use language preference if set, else keep Jellyfin default + if (subtitleLanguageCode) { + const langMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + if (langMatch !== undefined) return langMatch; + } + return defaultIndex; + } +} + /** * Get default play settings for an item. * * @param item - The media item to play * @param settings - User settings (language preferences, bitrate, etc.) * @param previous - Optional previous track selections to carry over (for sequential play) + * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV) */ export function getDefaultPlaySettings( item: BaseItemDto | null | undefined, settings: Settings | null, previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo }, + options?: PlaySettingsOptions, ): PlaySettings { const bitrate = settings?.defaultBitrate ?? BITRATES[0]; @@ -65,6 +194,10 @@ export function getDefaultPlaySettings( let audioIndex = mediaSource?.DefaultAudioStreamIndex; let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1; + // Track whether we matched previous selections (for language preference fallback) + let matchedPreviousAudio = false; + let matchedPreviousSubtitle = false; + // Try to match previous selections (sequential play) if (previous?.indexes && previous?.source && settings) { if ( @@ -79,7 +212,11 @@ export function getDefaultPlaySettings( streams, result, ); - subtitleIndex = result.DefaultSubtitleStreamIndex; + // Check if StreamRanker found a match (changed from default) + if (result.DefaultSubtitleStreamIndex !== subtitleIndex) { + subtitleIndex = result.DefaultSubtitleStreamIndex; + matchedPreviousSubtitle = true; + } } if ( @@ -94,7 +231,51 @@ export function getDefaultPlaySettings( streams, result, ); - audioIndex = result.DefaultAudioStreamIndex; + // Check if StreamRanker found a match (changed from default) + if (result.DefaultAudioStreamIndex !== audioIndex) { + audioIndex = result.DefaultAudioStreamIndex; + matchedPreviousAudio = true; + } + } + } + + // Apply language preferences when enabled (TV) and no previous selection matched + if (options?.applyLanguagePreferences && settings) { + const audioLanguageCode = + settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined; + const subtitleLanguageCode = + settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined; + + // Apply audio language preference if no previous selection matched + if (!matchedPreviousAudio && audioLanguageCode) { + const langMatch = findTrackByLanguage( + streams, + audioLanguageCode, + "Audio", + ); + if (langMatch !== undefined) { + audioIndex = langMatch; + } + } + + // Get the selected audio track's language for Smart mode + const selectedAudioTrack = streams.find( + (s) => s.Type === "Audio" && s.Index === audioIndex, + ); + const selectedAudioLanguage = + selectedAudioTrack?.Language ?? + selectedAudioTrack?.DisplayTitle ?? + undefined; + + // Apply subtitle mode logic if no previous selection matched + if (!matchedPreviousSubtitle) { + subtitleIndex = applySubtitleMode( + streams, + settings, + subtitleIndex, + selectedAudioLanguage, + subtitleLanguageCode, + ); } } From 55c74ab38340fcce7e308c414e30f8a690e50fb6 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:36:51 +0100 Subject: [PATCH 121/309] feat(player): enable language-based track selection on mobile --- components/DownloadItem.tsx | 6 +++++- components/ItemContent.tsx | 10 +++++++++- components/video-player/controls/Controls.tsx | 13 +++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e50b4efca..0923dfecd 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -73,12 +73,16 @@ export const DownloadItems: React.FC = ({ SelectedOptions | undefined >(undefined); + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(items[0], settings); + } = useDefaultPlaySettings(items[0], settings, playSettingsOptions); const userCanDownload = useMemo( () => user?.Policy?.EnableContentDownloading, diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 5217e5413..4869a75e5 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -75,12 +75,20 @@ const ItemContentMobile: React.FC = ({ >(undefined); // Use itemWithSources for play settings since it has MediaSources data + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(itemWithSources ?? item, settings); + } = useDefaultPlaySettings( + itemWithSources ?? item, + settings, + playSettingsOptions, + ); const logoUrl = useMemo( () => (item ? getLogoImageUrlById({ api, item }) : null), diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index a336143e9..1f1b37cca 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -339,10 +339,15 @@ export const Controls: FC = ({ mediaSource: newMediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings(item, settings, { - indexes: previousIndexes, - source: mediaSource ?? undefined, - }); + } = getDefaultPlaySettings( + item, + settings, + { + indexes: previousIndexes, + source: mediaSource ?? undefined, + }, + { applyLanguagePreferences: true }, + ); const queryParams = new URLSearchParams({ ...(offline && { offline: "true" }), From c5eb7b0c96d6beffcc2f8bf5de57312a52ca04cb Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:41:03 +0100 Subject: [PATCH 122/309] feat(tv): live tv initial commit --- .claude/learned-facts.md | 4 +- CLAUDE.md | 5 +- .../livetv/_layout.tsx | 14 +- .../livetv/programs.tsx | 11 +- .../InfiniteScrollingCollectionList.tv.tsx | 120 +++++---- components/library/TVLibraries.tsx | 4 + components/livetv/TVLiveTVPage.tsx | 254 ++++++++++++++++++ components/livetv/TVLiveTVPlaceholder.tsx | 46 ++++ translations/en.json | 10 +- 9 files changed, 408 insertions(+), 60 deletions(-) create mode 100644 components/livetv/TVLiveTVPage.tsx create mode 100644 components/livetv/TVLiveTVPlaceholder.tsx diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 964bd9e51..e11daa072 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -40,4 +40,6 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_ -- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ \ No newline at end of file +- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ + +- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 10e4f5597..9fd32e751 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,9 +142,9 @@ import { apiAtom } from "@/providers/JellyfinProvider"; ### TV Component Rendering Pattern -**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports. +**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead. -**Pattern for TV-specific components**: +**Pattern for TV-specific pages and components**: ```typescript // In page file (e.g., app/login.tsx) import { Platform } from "react-native"; @@ -164,6 +164,7 @@ export default LoginPage; - Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`) - Use `Platform.isTV` to conditionally render the appropriate component - TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling +- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly ### TV Option Selector Pattern (Dropdowns/Multi-select) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx index 072b2f931..28cc2f9da 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx @@ -7,7 +7,8 @@ import type { ParamListBase, TabNavigationState, } from "@react-navigation/native"; -import { Stack, withLayoutContext } from "expo-router"; +import { Slot, Stack, withLayoutContext } from "expo-router"; +import { Platform } from "react-native"; const { Navigator } = createMaterialTopTabNavigator(); @@ -19,6 +20,17 @@ export const Tab = withLayoutContext< >(Navigator); const Layout = () => { + // On TV, skip the Material Top Tab Navigator and render children directly + // The TV version handles its own tab navigation internally + if (Platform.isTV) { + return ( + <> + + + + ); + } + return ( <> diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx index 812d084d9..f1471e3a0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx @@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; export default function page() { + if (Platform.isTV) { + return ; + } + + return ; +} + +function MobileLiveTVPrograms() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 548dfed57..53b1e2edb 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -47,53 +47,71 @@ interface Props extends ViewProps { type Typography = ReturnType; -// TV-specific ItemCardText with larger fonts +// TV-specific ItemCardText with appropriately sized fonts const TVItemCardText: React.FC<{ item: BaseItemDto; typography: Typography; -}> = ({ item, typography }) => { + width?: number; +}> = ({ item, typography, width }) => { + const renderSubtitle = () => { + if (item.Type === "Episode") { + return ( + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + {" - "} + {item.SeriesName} + + ); + } + + if (item.Type === "Program") { + // For Live TV programs, show channel name + const channelName = item.ChannelName; + return channelName ? ( + + {channelName} + + ) : null; + } + + // Default: show production year + return item.ProductionYear ? ( + + {item.ProductionYear} + + ) : null; + }; + return ( - - {item.Type === "Episode" ? ( - <> - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - - ) : ( - <> - - {item.Name} - - - {item.ProductionYear} - - - )} + + + {item.Name} + + {renderSubtitle()} ); }; @@ -287,15 +305,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ } as any); }, [router, parentId]); - const getItemLayout = useCallback( - (_data: ArrayLike | null | undefined, index: number) => ({ - length: itemWidth + ITEM_GAP, - offset: (itemWidth + ITEM_GAP) * index, - index, - }), - [itemWidth], - ); - const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; @@ -359,7 +368,11 @@ export const InfiniteScrollingCollectionList: React.FC = ({ > {renderPoster()} - + ); }, @@ -462,7 +475,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ maxToRenderPerBatch={3} windowSize={5} removeClippedSubviews={false} - getItemLayout={getItemLayout} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} contentContainerStyle={{ diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index bc12201b8..4c985dc00 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -309,6 +309,10 @@ export const TVLibraries: React.FC = () => { const handleLibraryPress = useCallback( (library: BaseItemDto) => { + if (library.CollectionType === "livetv") { + router.push("/(auth)/(tabs)/(libraries)/livetv/programs"); + return; + } if (library.CollectionType === "music") { router.push({ pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`, diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx new file mode 100644 index 000000000..b88f32df4 --- /dev/null +++ b/components/livetv/TVLiveTVPage.tsx @@ -0,0 +1,254 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder"; +import { TVTabButton } from "@/components/tv/TVTabButton"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const HORIZONTAL_PADDING = 60; +const SECTION_GAP = 24; + +type TabId = + | "programs" + | "guide" + | "channels" + | "recordings" + | "schedule" + | "series"; + +interface Tab { + id: TabId; + labelKey: string; +} + +const TABS: Tab[] = [ + { id: "programs", labelKey: "live_tv.tabs.programs" }, + { id: "guide", labelKey: "live_tv.tabs.guide" }, + { id: "channels", labelKey: "live_tv.tabs.channels" }, + { id: "recordings", labelKey: "live_tv.tabs.recordings" }, + { id: "schedule", labelKey: "live_tv.tabs.schedule" }, + { id: "series", labelKey: "live_tv.tabs.series" }, +]; + +export const TVLiveTVPage: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [activeTab, setActiveTab] = useState("programs"); + + // Section configurations for Programs tab + const sections = useMemo(() => { + if (!api || !user?.Id) return []; + + return [ + { + title: t("live_tv.on_now"), + queryKey: ["livetv", "tv", "onNow"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getRecommendedPrograms({ + userId: user.Id, + isAiring: true, + limit: 24, + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.shows"), + queryKey: ["livetv", "tv", "shows"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: false, + isSeries: true, + isSports: false, + isNews: false, + isKids: false, + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.movies"), + queryKey: ["livetv", "tv", "movies"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.sports"), + queryKey: ["livetv", "tv", "sports"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isSports: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.for_kids"), + queryKey: ["livetv", "tv", "kids"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isKids: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.news"), + queryKey: ["livetv", "tv", "news"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isNews: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + ]; + }, [api, user?.Id, t]); + + const handleTabSelect = useCallback((tabId: TabId) => { + setActiveTab(tabId); + }, []); + + const renderProgramsContent = () => ( + + + {sections.map((section) => ( + + ))} + + + ); + + const renderTabContent = () => { + if (activeTab === "programs") { + return renderProgramsContent(); + } + + // Placeholder for other tabs + const tab = TABS.find((t) => t.id === activeTab); + return ; + }; + + return ( + + {/* Header with Title and Tabs */} + + {/* Title */} + + Live TV + + + {/* Tab Bar */} + + {TABS.map((tab, index) => ( + handleTabSelect(tab.id)} + hasTVPreferredFocus={index === 0} + switchOnFocus={true} + /> + ))} + + + + {/* Tab Content */} + {renderTabContent()} + + ); +}; diff --git a/components/livetv/TVLiveTVPlaceholder.tsx b/components/livetv/TVLiveTVPlaceholder.tsx new file mode 100644 index 000000000..2880cbedd --- /dev/null +++ b/components/livetv/TVLiveTVPlaceholder.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVLiveTVPlaceholderProps { + tabName: string; +} + +export const TVLiveTVPlaceholder: React.FC = ({ + tabName, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + return ( + + + {tabName} + + + {t("live_tv.coming_soon")} + + + ); +}; diff --git a/translations/en.json b/translations/en.json index 05663b326..3f88d0315 100644 --- a/translations/en.json +++ b/translations/en.json @@ -717,7 +717,15 @@ "movies": "Movies", "sports": "Sports", "for_kids": "For Kids", - "news": "News" + "news": "News", + "tabs": { + "programs": "Programs", + "guide": "Guide", + "channels": "Channels", + "recordings": "Recordings", + "schedule": "Schedule", + "series": "Series" + } }, "jellyseerr": { "confirm": "Confirm", From a0dd752d8f9f7d1f4a90fa9f0c3fed1ab836df6a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:42:10 +0100 Subject: [PATCH 123/309] feat(tv): add channels tab with direct channel playback and live tv controls --- components/livetv/TVChannelCard.tsx | 183 ++++++++++++++++++ components/livetv/TVChannelsGrid.tsx | 136 +++++++++++++ components/livetv/TVLiveTVPage.tsx | 14 +- .../video-player/controls/Controls.tv.tsx | 140 +++++++++++--- translations/en.json | 4 + 5 files changed, 450 insertions(+), 27 deletions(-) create mode 100644 components/livetv/TVChannelCard.tsx create mode 100644 components/livetv/TVChannelsGrid.tsx diff --git a/components/livetv/TVChannelCard.tsx b/components/livetv/TVChannelCard.tsx new file mode 100644 index 000000000..7ac47b71d --- /dev/null +++ b/components/livetv/TVChannelCard.tsx @@ -0,0 +1,183 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Animated, Image, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface TVChannelCardProps { + channel: BaseItemDto; + api: Api | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const CARD_WIDTH = 200; +const CARD_HEIGHT = 160; + +export const TVChannelCard: React.FC = ({ + channel, + api, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const imageUrl = getPrimaryImageUrl({ + api, + item: channel, + quality: 80, + width: 200, + }); + + return ( + + + {/* Channel logo or number */} + + {imageUrl ? ( + + ) : ( + + + {channel.ChannelNumber || "?"} + + + )} + + + {/* Channel name */} + + {channel.Name} + + + {/* Channel number (if name is shown) */} + {channel.ChannelNumber && ( + + Ch. {channel.ChannelNumber} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + }, + container: { + flex: 1, + borderRadius: 12, + borderWidth: 1, + padding: 12, + alignItems: "center", + justifyContent: "center", + }, + focusedShadow: { + shadowColor: "#FFFFFF", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 12, + }, + logoContainer: { + width: 80, + height: 60, + marginBottom: 8, + justifyContent: "center", + alignItems: "center", + }, + logo: { + width: "100%", + height: "100%", + }, + numberFallback: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + }, + numberText: { + fontWeight: "bold", + }, + channelName: { + fontWeight: "600", + textAlign: "center", + marginBottom: 4, + }, + channelNumber: { + fontWeight: "400", + }, +}); + +export { CARD_WIDTH, CARD_HEIGHT }; diff --git a/components/livetv/TVChannelsGrid.tsx b/components/livetv/TVChannelsGrid.tsx new file mode 100644 index 000000000..f93beb35c --- /dev/null +++ b/components/livetv/TVChannelsGrid.tsx @@ -0,0 +1,136 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { TVChannelCard } from "./TVChannelCard"; + +const HORIZONTAL_PADDING = 60; +const GRID_GAP = 16; + +export const TVChannelsGrid: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + // Fetch all channels + const { data: channelsData, isLoading } = useQuery({ + queryKey: ["livetv", "channels-grid", "all"], + queryFn: async () => { + if (!api || !user?.Id) return null; + const res = await getLiveTvApi(api).getLiveTvChannels({ + enableFavoriteSorting: true, + userId: user.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + enabled: !!api && !!user?.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const channels = channelsData?.Items ?? []; + + const handleChannelPress = useCallback( + (channelId: string | undefined) => { + if (channelId) { + // Navigate directly to the player to start the channel + const queryParams = new URLSearchParams({ + itemId: channelId, + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + } + }, + [router], + ); + + if (isLoading) { + return ( + + + + ); + } + + if (channels.length === 0) { + return ( + + + {t("live_tv.no_channels")} + + + ); + } + + return ( + + + {channels.map((channel, index) => ( + handleChannelPress(channel.Id)} + // No hasTVPreferredFocus - tab buttons handle initial focus + /> + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingTop: 24, + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-start", + gap: GRID_GAP, + overflow: "visible", + paddingVertical: 10, // Extra padding for focus scale animation + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, +}); diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx index b88f32df4..fffa77384 100644 --- a/components/livetv/TVLiveTVPage.tsx +++ b/components/livetv/TVLiveTVPage.tsx @@ -6,6 +6,8 @@ import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid"; +import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide"; import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder"; import { TVTabButton } from "@/components/tv/TVTabButton"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -200,6 +202,14 @@ export const TVLiveTVPage: React.FC = () => { return renderProgramsContent(); } + if (activeTab === "guide") { + return ; + } + + if (activeTab === "channels") { + return ; + } + // Placeholder for other tabs const tab = TABS.find((t) => t.id === activeTab); return ; @@ -234,13 +244,13 @@ export const TVLiveTVPage: React.FC = () => { gap: 8, }} > - {TABS.map((tab, index) => ( + {TABS.map((tab) => ( handleTabSelect(tab.id)} - hasTVPreferredFocus={index === 0} + hasTVPreferredFocus={activeTab === tab.id} switchOnFocus={true} /> ))} diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index dad6c0821..18369ab4e 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -375,6 +375,12 @@ export const Controls: FC = ({ isSeeking, }); + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) + const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; + + // For live TV, determine if we're at the live edge (within 5 seconds of max) + const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms + const getFinishTime = () => { const now = new Date(); const finishTime = new Date(now.getTime() + remainingTime); @@ -540,6 +546,13 @@ export const Controls: FC = ({ ); const handleSeekForwardButton = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; seek(newPosition); @@ -556,7 +569,14 @@ export const Controls: FC = ({ }, 2000); controlsInteractionRef.current(); - }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); const handleSeekBackwardButton = useCallback(() => { const newPosition = Math.max(min.value, progress.value - 30 * 1000); @@ -579,6 +599,13 @@ export const Controls: FC = ({ // Progress bar D-pad seeking (10s increments for finer control) const handleProgressSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + const newPosition = Math.min(max.value, progress.value + 10 * 1000); progress.value = newPosition; seek(newPosition); @@ -595,7 +622,14 @@ export const Controls: FC = ({ }, 2000); controlsInteractionRef.current(); - }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); const handleProgressSeekLeft = useCallback(() => { const newPosition = Math.max(min.value, progress.value - 10 * 1000); @@ -618,6 +652,12 @@ export const Controls: FC = ({ // Minimal seek mode handlers (only show progress bar, not full controls) const handleMinimalSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + return; + } + const newPosition = Math.min(max.value, progress.value + 10 * 1000); progress.value = newPosition; seek(newPosition); @@ -642,6 +682,7 @@ export const Controls: FC = ({ calculateTrickplayUrl, updateSeekBubbleTime, showMinimalSeek, + isLiveTV, ]); const handleMinimalSeekLeft = useCallback(() => { @@ -691,11 +732,23 @@ export const Controls: FC = ({ }, [startMinimalSeekHideTimeout]); const startContinuousSeekForward = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't start continuous seeking + return; + } + seekAccelerationRef.current = 1; handleSeekForwardButton(); continuousSeekRef.current = setInterval(() => { + // For live TV, stop continuous seeking when we hit the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + stopContinuousSeeking(); + return; + } + const seekAmount = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * seekAccelerationRef.current * @@ -718,6 +771,8 @@ export const Controls: FC = ({ seek, calculateTrickplayUrl, updateSeekBubbleTime, + isLiveTV, + stopContinuousSeeking, ]); const startContinuousSeekBackward = useCallback(() => { @@ -977,16 +1032,18 @@ export const Controls: FC = ({ {formatTimeString(currentTime, "ms")} - - - -{formatTimeString(remainingTime, "ms")} - - - {t("player.ends_at")} {getFinishTime()} - - + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} @@ -1012,9 +1069,25 @@ export const Controls: FC = ({ style={[styles.subtitleText, { fontSize: typography.body }]} >{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} - - {item?.Name} - + + + {item?.Name} + + {isLiveTV && ( + + + {t("player.live")} + + + )} + {item?.Type === "Movie" && ( = ({ {formatTimeString(currentTime, "ms")} - - - -{formatTimeString(remainingTime, "ms")} - - - {t("player.ends_at")} {getFinishTime()} - - + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} @@ -1160,6 +1235,11 @@ const styles = StyleSheet.create({ metadataContainer: { marginBottom: 16, }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, subtitleText: { color: "rgba(255,255,255,0.6)", }, @@ -1167,6 +1247,16 @@ const styles = StyleSheet.create({ color: "#fff", fontWeight: "bold", }, + liveBadge: { + backgroundColor: "#EF4444", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 6, + }, + liveBadgeText: { + color: "#FFF", + fontWeight: "bold", + }, controlButtonsRow: { flexDirection: "row", alignItems: "center", diff --git a/translations/en.json b/translations/en.json index 3f88d0315..b3c6963f2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -624,6 +624,7 @@ "no_links": "No Links" }, "player": { + "live": "LIVE", "error": "Error", "failed_to_get_stream_url": "Failed to get the stream URL", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", @@ -718,6 +719,9 @@ "sports": "Sports", "for_kids": "For Kids", "news": "News", + "page_of": "Page {{current}} of {{total}}", + "no_programs": "No programs available", + "no_channels": "No channels available", "tabs": { "programs": "Programs", "guide": "Guide", From 246e0af0f6136b9b34413551ad036cbfc80ad577 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:53:13 +0100 Subject: [PATCH 124/309] feat(tv): improve live tv guide scrolling and time range --- components/livetv/TVGuideChannelRow.tsx | 146 ++++++++ components/livetv/TVGuideTimeHeader.tsx | 64 ++++ components/livetv/TVLiveTVGuide.tsx | 433 ++++++++++++++++++++++++ 3 files changed, 643 insertions(+) create mode 100644 components/livetv/TVGuideChannelRow.tsx create mode 100644 components/livetv/TVGuideTimeHeader.tsx create mode 100644 components/livetv/TVLiveTVGuide.tsx diff --git a/components/livetv/TVGuideChannelRow.tsx b/components/livetv/TVGuideChannelRow.tsx new file mode 100644 index 000000000..73ba74915 --- /dev/null +++ b/components/livetv/TVGuideChannelRow.tsx @@ -0,0 +1,146 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React, { useMemo } from "react"; +import { StyleSheet, View } from "react-native"; +import { TVGuideProgramCell } from "./TVGuideProgramCell"; + +interface TVGuideChannelRowProps { + programs: BaseItemDto[]; + baseTime: Date; + pixelsPerHour: number; + minProgramWidth: number; + hoursToShow: number; + onProgramPress: (program: BaseItemDto) => void; + disabled?: boolean; + firstProgramRefSetter?: (ref: View | null) => void; +} + +export const TVGuideChannelRow: React.FC = ({ + programs, + baseTime, + pixelsPerHour, + minProgramWidth, + hoursToShow, + onProgramPress, + disabled = false, + firstProgramRefSetter, +}) => { + const isCurrentlyAiring = (program: BaseItemDto): boolean => { + if (!program.StartDate || !program.EndDate) return false; + const now = new Date(); + const start = new Date(program.StartDate); + const end = new Date(program.EndDate); + return now >= start && now <= end; + }; + + const getTimeOffset = (startDate: string): number => { + const start = new Date(startDate); + const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000; + return Math.max(0, (diffMinutes / 60) * pixelsPerHour); + }; + + // Filter programs for this channel and within the time window + const filteredPrograms = useMemo(() => { + const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000); + + return programs + .filter((p) => { + if (!p.StartDate || !p.EndDate) return false; + const start = new Date(p.StartDate); + const end = new Date(p.EndDate); + // Program overlaps with our time window + return end > baseTime && start < endTime; + }) + .sort((a, b) => { + const dateA = new Date(a.StartDate || 0); + const dateB = new Date(b.StartDate || 0); + return dateA.getTime() - dateB.getTime(); + }); + }, [programs, baseTime, hoursToShow]); + + // Calculate program cells with positions (absolute positioning) + const programCells = useMemo(() => { + return filteredPrograms.map((program) => { + if (!program.StartDate || !program.EndDate) { + return { program, width: minProgramWidth, left: 0 }; + } + + // Clamp the start time to baseTime if program started earlier + const programStart = new Date(program.StartDate); + const effectiveStart = programStart < baseTime ? baseTime : programStart; + + // Clamp the end time to the window end + const windowEnd = new Date( + baseTime.getTime() + hoursToShow * 60 * 60 * 1000, + ); + const programEnd = new Date(program.EndDate); + const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd; + + const durationMinutes = + (effectiveEnd.getTime() - effectiveStart.getTime()) / 60000; + const width = Math.max( + (durationMinutes / 60) * pixelsPerHour - 4, + minProgramWidth, + ); // -4 for gap + + const left = getTimeOffset(effectiveStart.toISOString()); + + return { + program, + width, + left, + }; + }); + }, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]); + + const totalWidth = hoursToShow * pixelsPerHour; + + return ( + + {programCells.map(({ program, width, left }, index) => ( + + onProgramPress(program)} + disabled={disabled} + refSetter={index === 0 ? firstProgramRefSetter : undefined} + /> + + ))} + + {/* Empty state */} + {programCells.length === 0 && ( + + {/* Empty row indicator */} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 80, + position: "relative", + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + backgroundColor: "rgba(20, 20, 20, 1)", + }, + programCellWrapper: { + position: "absolute", + top: 4, + bottom: 4, + }, + noPrograms: { + position: "absolute", + left: 4, + top: 4, + bottom: 4, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderRadius: 8, + }, +}); diff --git a/components/livetv/TVGuideTimeHeader.tsx b/components/livetv/TVGuideTimeHeader.tsx new file mode 100644 index 000000000..a3ca8348a --- /dev/null +++ b/components/livetv/TVGuideTimeHeader.tsx @@ -0,0 +1,64 @@ +import { BlurView } from "expo-blur"; +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuideTimeHeaderProps { + baseTime: Date; + hoursToShow: number; + pixelsPerHour: number; +} + +export const TVGuideTimeHeader: React.FC = ({ + baseTime, + hoursToShow, + pixelsPerHour, +}) => { + const typography = useScaledTVTypography(); + + const hours: Date[] = []; + for (let i = 0; i < hoursToShow; i++) { + const hour = new Date(baseTime); + hour.setMinutes(0, 0, 0); + hour.setHours(baseTime.getHours() + i); + hours.push(hour); + } + + const formatHour = (date: Date) => { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + {hours.map((hour, index) => ( + + + {formatHour(hour)} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + height: 44, + }, + hourCell: { + justifyContent: "center", + paddingLeft: 12, + borderLeftWidth: 1, + borderLeftColor: "rgba(255, 255, 255, 0.1)", + }, + hourText: { + color: "rgba(255, 255, 255, 0.6)", + fontWeight: "500", + }, +}); diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx new file mode 100644 index 000000000..7c1f12f64 --- /dev/null +++ b/components/livetv/TVLiveTVGuide.tsx @@ -0,0 +1,433 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { TVGuideChannelRow } from "./TVGuideChannelRow"; +import { TVGuidePageNavigation } from "./TVGuidePageNavigation"; +import { TVGuideTimeHeader } from "./TVGuideTimeHeader"; + +// Design constants +const CHANNEL_COLUMN_WIDTH = 240; +const PIXELS_PER_HOUR = 250; +const ROW_HEIGHT = 80; +const TIME_HEADER_HEIGHT = 44; +const CHANNELS_PER_PAGE = 20; +const MIN_PROGRAM_WIDTH = 80; +const HORIZONTAL_PADDING = 60; + +// Channel label component +const ChannelLabel: React.FC<{ + channel: BaseItemDto; + typography: ReturnType; +}> = ({ channel, typography }) => ( + + + {channel.ChannelNumber} + + + {channel.Name} + + +); + +export const TVLiveTVGuide: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [currentPage, setCurrentPage] = useState(1); + + // Scroll refs for synchronization + const channelListRef = useRef(null); + const mainVerticalRef = useRef(null); + + // Focus guide refs for bidirectional navigation + const [firstProgramRef, setFirstProgramRef] = useState(null); + const [prevButtonRef, setPrevButtonRef] = useState(null); + + // Base time - start of current hour, end time - end of day + const [{ baseTime, endOfDay, hoursToShow }] = useState(() => { + const now = new Date(); + now.setMinutes(0, 0, 0); + + const endOfDayTime = new Date(now); + endOfDayTime.setHours(23, 59, 59, 999); + + const hoursUntilEndOfDay = Math.ceil( + (endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000), + ); + + return { + baseTime: now, + endOfDay: endOfDayTime, + hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour + }; + }); + + // Current time indicator position (relative to program grid start) + const [currentTimeOffset, setCurrentTimeOffset] = useState(0); + + // Update current time indicator every minute + useEffect(() => { + const updateCurrentTime = () => { + const now = new Date(); + const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000; + const offset = (diffMinutes / 60) * PIXELS_PER_HOUR; + setCurrentTimeOffset(offset); + }; + + updateCurrentTime(); + const interval = setInterval(updateCurrentTime, 60000); + return () => clearInterval(interval); + }, [baseTime]); + + // Sync vertical scroll between channel list and main grid + const handleVerticalScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetY = event.nativeEvent.contentOffset.y; + channelListRef.current?.scrollTo({ y: offsetY, animated: false }); + }, + [], + ); + + // Fetch channels + const { data: channelsData, isLoading: isLoadingChannels } = useQuery({ + queryKey: ["livetv", "tv-guide", "channels"], + queryFn: async () => { + if (!api || !user?.Id) return null; + const res = await getLiveTvApi(api).getLiveTvChannels({ + enableFavoriteSorting: true, + userId: user.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + enabled: !!api && !!user?.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const totalChannels = channelsData?.TotalRecordCount ?? 0; + const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE); + const allChannels = channelsData?.Items ?? []; + + // Get channels for current page + const paginatedChannels = useMemo(() => { + const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE; + return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE); + }, [allChannels, currentPage]); + + const channelIds = useMemo( + () => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[], + [paginatedChannels], + ); + + // Fetch programs for visible channels + const { data: programsData, isLoading: isLoadingPrograms } = useQuery({ + queryKey: [ + "livetv", + "tv-guide", + "programs", + channelIds, + baseTime.toISOString(), + endOfDay.toISOString(), + ], + queryFn: async () => { + if (!api || channelIds.length === 0) return null; + const res = await getLiveTvApi(api).getPrograms({ + getProgramsDto: { + MaxStartDate: endOfDay.toISOString(), + MinEndDate: baseTime.toISOString(), + ChannelIds: channelIds, + ImageTypeLimit: 1, + EnableImages: false, + SortBy: ["StartDate"], + EnableTotalRecordCount: false, + EnableUserData: false, + }, + }); + return res.data; + }, + enabled: channelIds.length > 0, + staleTime: 2 * 60 * 1000, // 2 minutes + }); + + const programs = programsData?.Items ?? []; + + // Group programs by channel + const programsByChannel = useMemo(() => { + const grouped: Record = {}; + for (const program of programs) { + const channelId = program.ChannelId; + if (channelId) { + if (!grouped[channelId]) { + grouped[channelId] = []; + } + grouped[channelId].push(program); + } + } + return grouped; + }, [programs]); + + const handleProgramPress = useCallback( + (program: BaseItemDto) => { + // Navigate to play the program/channel + const queryParams = new URLSearchParams({ + itemId: program.Id ?? "", + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + }, + [router], + ); + + const handlePreviousPage = useCallback(() => { + if (currentPage > 1) { + setCurrentPage((p) => p - 1); + } + }, [currentPage]); + + const handleNextPage = useCallback(() => { + if (currentPage < totalPages) { + setCurrentPage((p) => p + 1); + } + }, [currentPage, totalPages]); + + const isLoading = isLoadingChannels; + const totalWidth = hoursToShow * PIXELS_PER_HOUR; + + if (isLoading) { + return ( + + + + ); + } + + if (paginatedChannels.length === 0) { + return ( + + + {t("live_tv.no_programs")} + + + ); + } + + return ( + + {/* Page Navigation */} + {totalPages > 1 && ( + + + + )} + + {/* Bidirectional focus guides */} + {firstProgramRef && ( + + )} + {prevButtonRef && ( + + )} + + {/* Main grid container */} + + {/* Fixed channel column */} + + {/* Spacer for time header */} + + + {/* Channel labels - synced with main scroll */} + + {paginatedChannels.map((channel, index) => ( + + ))} + + + + {/* Scrollable programs area */} + + + {/* Time header */} + + + {/* Programs grid - vertical scroll */} + + {paginatedChannels.map((channel, index) => { + const channelPrograms = channel.Id + ? (programsByChannel[channel.Id] ?? []) + : []; + return ( + + ); + })} + + + {/* Current time indicator */} + {currentTimeOffset > 0 && currentTimeOffset < totalWidth && ( + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, + gridWrapper: { + flex: 1, + flexDirection: "row", + }, + channelColumn: { + backgroundColor: "rgba(40, 40, 40, 1)", + borderRightWidth: 1, + borderRightColor: "rgba(255, 255, 255, 0.2)", + }, + channelLabel: { + height: ROW_HEIGHT, + justifyContent: "center", + paddingHorizontal: 12, + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + }, + channelNumber: { + color: "rgba(255, 255, 255, 0.5)", + fontWeight: "400", + marginBottom: 2, + }, + channelName: { + color: "#FFFFFF", + fontWeight: "600", + }, + horizontalScroll: { + flex: 1, + }, + currentTimeIndicator: { + position: "absolute", + width: 2, + backgroundColor: "#EF4444", + zIndex: 10, + pointerEvents: "none", + }, +}); From 9d6a9decc9b8743a9932211bd33bd8fdd646e37e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:59:30 +0100 Subject: [PATCH 125/309] style(tv): match live tv header styling to home tab --- components/livetv/TVLiveTVPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx index fffa77384..a3f3ed45d 100644 --- a/components/livetv/TVLiveTVPage.tsx +++ b/components/livetv/TVLiveTVPage.tsx @@ -14,6 +14,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; const SECTION_GAP = 24; type TabId = @@ -220,7 +221,7 @@ export const TVLiveTVPage: React.FC = () => { {/* Header with Title and Tabs */} { {/* Title */} Date: Mon, 26 Jan 2026 20:30:50 +0100 Subject: [PATCH 126/309] feat(tv): live tv --- components/livetv/TVGuidePageNavigation.tsx | 154 ++++++++++++++++++++ components/livetv/TVGuideProgramCell.tsx | 148 +++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 components/livetv/TVGuidePageNavigation.tsx create mode 100644 components/livetv/TVGuideProgramCell.tsx diff --git a/components/livetv/TVGuidePageNavigation.tsx b/components/livetv/TVGuidePageNavigation.tsx new file mode 100644 index 000000000..5188c54ea --- /dev/null +++ b/components/livetv/TVGuidePageNavigation.tsx @@ -0,0 +1,154 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuidePageNavigationProps { + currentPage: number; + totalPages: number; + onPrevious: () => void; + onNext: () => void; + disabled?: boolean; + prevButtonRefSetter?: (ref: View | null) => void; +} + +interface NavButtonProps { + onPress: () => void; + icon: keyof typeof Ionicons.glyphMap; + label: string; + isDisabled: boolean; + disabled?: boolean; + refSetter?: (ref: View | null) => void; +} + +const NavButton: React.FC = ({ + onPress, + icon, + label, + isDisabled, + disabled = false, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const visuallyDisabled = isDisabled || disabled; + + const handlePress = () => { + if (!visuallyDisabled) { + onPress(); + } + }; + + return ( + + + + + {label} + + + + ); +}; + +export const TVGuidePageNavigation: React.FC = ({ + currentPage, + totalPages, + onPrevious, + onNext, + disabled = false, + prevButtonRefSetter, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + return ( + + + + + = totalPages} + disabled={disabled} + /> + + + + {t("live_tv.page_of", { current: currentPage, total: totalPages })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 16, + }, + buttonsContainer: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + navButton: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + navButtonText: { + fontWeight: "600", + }, + pageText: { + color: "rgba(255, 255, 255, 0.6)", + }, +}); diff --git a/components/livetv/TVGuideProgramCell.tsx b/components/livetv/TVGuideProgramCell.tsx new file mode 100644 index 000000000..e8287132e --- /dev/null +++ b/components/livetv/TVGuideProgramCell.tsx @@ -0,0 +1,148 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuideProgramCellProps { + program: BaseItemDto; + width: number; + isCurrentlyAiring: boolean; + onPress: () => void; + disabled?: boolean; + refSetter?: (ref: View | null) => void; +} + +export const TVGuideProgramCell: React.FC = ({ + program, + width, + isCurrentlyAiring, + onPress, + disabled = false, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ + scaleAmount: 1, + duration: 120, + }); + + const formatTime = (date: string | null | undefined) => { + if (!date) return ""; + const d = new Date(date); + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + + {/* LIVE badge */} + {isCurrentlyAiring && ( + + + LIVE + + + )} + + {/* Program name */} + + {program.Name} + + + {/* Time range */} + + {formatTime(program.StartDate)} - {formatTime(program.EndDate)} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 70, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + justifyContent: "center", + overflow: "hidden", + }, + focusedShadow: { + shadowColor: "#FFFFFF", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 12, + }, + liveBadge: { + position: "absolute", + top: 6, + right: 6, + backgroundColor: "#EF4444", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + zIndex: 10, + elevation: 10, + }, + liveBadgeText: { + color: "#FFFFFF", + fontWeight: "bold", + }, + programName: { + fontWeight: "600", + marginBottom: 4, + }, + timeText: { + fontWeight: "400", + }, +}); From 1cbb46f0ca06561d115a44aeecfbc06acb4a8ed4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 20:46:42 +0100 Subject: [PATCH 127/309] feat(player): add mpv cache and buffer configuration --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 100 ++++++++++++++++++ .../settings/playback-controls/page.tsx | 2 + app/(auth)/player/direct-player.tsx | 11 ++ components/settings/MpvBufferSettings.tsx | 100 ++++++++++++++++++ modules/mpv-player/ios/MPVLayerRenderer.swift | 21 +++- modules/mpv-player/ios/MpvPlayerModule.swift | 13 ++- modules/mpv-player/ios/MpvPlayerView.swift | 27 ++++- modules/mpv-player/src/MpvPlayer.types.ts | 11 ++ translations/en.json | 10 ++ utils/atoms/settings.ts | 13 +++ 10 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 components/settings/MpvBufferSettings.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 5f2afc8f6..84cd7da2b 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -20,6 +20,7 @@ import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, + type MpvCacheMode, TVTypographyScale, useSettings, } from "@/utils/atoms/settings"; @@ -47,6 +48,7 @@ export default function SettingsTV() { const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; const currentTypographyScale = settings.tvTypographyScale || TVTypographyScale.Default; + const currentCacheMode = settings.mpvCacheEnabled ?? "auto"; // Audio transcoding options const audioTranscodeModeOptions: TVOptionItem[] = useMemo( @@ -138,6 +140,28 @@ export default function SettingsTV() { [currentAlignY], ); + // Cache mode options + const cacheModeOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.buffer.cache_auto"), + value: "auto", + selected: currentCacheMode === "auto", + }, + { + label: t("home.settings.buffer.cache_yes"), + value: "yes", + selected: currentCacheMode === "yes", + }, + { + label: t("home.settings.buffer.cache_no"), + value: "no", + selected: currentCacheMode === "no", + }, + ], + [t, currentCacheMode], + ); + // Typography scale options const typographyScaleOptions: TVOptionItem[] = useMemo( () => [ @@ -191,6 +215,11 @@ export default function SettingsTV() { return option?.label || t("home.settings.appearance.display_size_default"); }, [typographyScaleOptions, t]); + const cacheModeLabel = useMemo(() => { + const option = cacheModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.buffer.cache_auto"); + }, [cacheModeOptions, t]); + return ( @@ -382,6 +411,77 @@ export default function SettingsTV() { "Get your free API key at opensubtitles.com/en/consumers"} + {/* Buffer Settings Section */} + + + showOptions({ + title: t("home.settings.buffer.cache_mode"), + options: cacheModeOptions, + onSelect: (value) => updateSettings({ mpvCacheEnabled: value }), + }) + } + /> + { + const newValue = Math.max( + 5, + (settings.mpvCacheSeconds ?? 10) - 5, + ); + updateSettings({ mpvCacheSeconds: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 120, + (settings.mpvCacheSeconds ?? 10) + 5, + ); + updateSettings({ mpvCacheSeconds: newValue }); + }} + formatValue={(v) => `${v}s`} + /> + { + const newValue = Math.max( + 50, + (settings.mpvDemuxerMaxBytes ?? 150) - 25, + ); + updateSettings({ mpvDemuxerMaxBytes: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 500, + (settings.mpvDemuxerMaxBytes ?? 150) + 25, + ); + updateSettings({ mpvDemuxerMaxBytes: newValue }); + }} + formatValue={(v) => `${v} MB`} + /> + { + const newValue = Math.max( + 25, + (settings.mpvDemuxerMaxBackBytes ?? 50) - 25, + ); + updateSettings({ mpvDemuxerMaxBackBytes: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 200, + (settings.mpvDemuxerMaxBackBytes ?? 50) + 25, + ); + updateSettings({ mpvDemuxerMaxBackBytes: newValue }); + }} + formatValue={(v) => `${v} MB`} + /> + {/* Appearance Section */} + {!Platform.isTV && } diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 13c88a924..17fa7506e 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -587,6 +587,13 @@ export default function page() { autoplay: true, initialSubtitleId, initialAudioId, + // Pass cache/buffer settings from user preferences + cacheConfig: { + enabled: settings.mpvCacheEnabled, + cacheSeconds: settings.mpvCacheSeconds, + maxBytes: settings.mpvDemuxerMaxBytes, + maxBackBytes: settings.mpvDemuxerMaxBackBytes, + }, }; // Add external subtitles only for online playback @@ -612,6 +619,10 @@ export default function page() { subtitleIndex, audioIndex, offline, + settings.mpvCacheEnabled, + settings.mpvCacheSeconds, + settings.mpvDemuxerMaxBytes, + settings.mpvDemuxerMaxBackBytes, ]); const volumeUpCb = useCallback(async () => { diff --git a/components/settings/MpvBufferSettings.tsx b/components/settings/MpvBufferSettings.tsx new file mode 100644 index 000000000..6df374123 --- /dev/null +++ b/components/settings/MpvBufferSettings.tsx @@ -0,0 +1,100 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Stepper } from "@/components/inputs/Stepper"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; +import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [ + { key: "home.settings.buffer.cache_auto", value: "auto" }, + { key: "home.settings.buffer.cache_yes", value: "yes" }, + { key: "home.settings.buffer.cache_no", value: "no" }, +]; + +export const MpvBufferSettings: React.FC = () => { + const { settings, updateSettings } = useSettings(); + const { t } = useTranslation(); + + const cacheModeOptions = useMemo( + () => [ + { + options: CACHE_MODE_OPTIONS.map((option) => ({ + type: "radio" as const, + label: t(option.key), + value: option.value, + selected: option.value === (settings?.mpvCacheEnabled ?? "auto"), + onPress: () => updateSettings({ mpvCacheEnabled: option.value }), + })), + }, + ], + [settings?.mpvCacheEnabled, t, updateSettings], + ); + + const currentCacheModeLabel = useMemo(() => { + const option = CACHE_MODE_OPTIONS.find( + (o) => o.value === (settings?.mpvCacheEnabled ?? "auto"), + ); + return option ? t(option.key) : t("home.settings.buffer.cache_auto"); + }, [settings?.mpvCacheEnabled, t]); + + if (!settings) return null; + + return ( + + + + + {currentCacheModeLabel} + + + + } + title={t("home.settings.buffer.cache_mode")} + /> + + + + updateSettings({ mpvCacheSeconds: value })} + appendValue='s' + /> + + + + updateSettings({ mpvDemuxerMaxBytes: value })} + appendValue=' MB' + /> + + + + + updateSettings({ mpvDemuxerMaxBackBytes: value }) + } + appendValue=' MB' + /> + + + ); +}; diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index af55826f3..a6f00fd1d 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -300,7 +300,11 @@ final class MPVLayerRenderer { startPosition: Double? = nil, externalSubtitles: [String]? = nil, initialSubtitleId: Int? = nil, - initialAudioId: Int? = nil + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil ) { currentPreset = preset currentURL = url @@ -323,6 +327,21 @@ final class MPVLayerRenderer { // Stop previous playback before loading new file self.command(handle, ["stop"]) self.updateHTTPHeaders(headers) + + // Apply cache/buffer settings + if let cacheMode = cacheEnabled { + self.setProperty(name: "cache", value: cacheMode) + } + if let cacheSecs = cacheSeconds { + self.setProperty(name: "cache-secs", value: String(cacheSecs)) + } + if let maxBytes = demuxerMaxBytes { + self.setProperty(name: "demuxer-max-bytes", value: "\(maxBytes)MiB") + } + if let maxBackBytes = demuxerMaxBackBytes { + self.setProperty(name: "demuxer-max-back-bytes", value: "\(maxBackBytes)MiB") + } + // Set start position if let startPos = startPosition, startPos > 0 { self.setProperty(name: "start", value: String(format: "%.2f", startPos)) diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index b60a3d406..c85c7fa3b 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -29,7 +29,10 @@ public class MpvPlayerModule: Module { guard let source = source, let urlString = source["url"] as? String, let videoURL = URL(string: urlString) else { return } - + + // Parse cache config if provided + let cacheConfig = source["cacheConfig"] as? [String: Any] + let config = VideoLoadConfig( url: videoURL, headers: source["headers"] as? [String: String], @@ -37,9 +40,13 @@ public class MpvPlayerModule: Module { startPosition: source["startPosition"] as? Double, autoplay: (source["autoplay"] as? Bool) ?? true, initialSubtitleId: source["initialSubtitleId"] as? Int, - initialAudioId: source["initialAudioId"] as? Int + initialAudioId: source["initialAudioId"] as? Int, + cacheEnabled: cacheConfig?["enabled"] as? String, + cacheSeconds: cacheConfig?["cacheSeconds"] as? Int, + demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int, + demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int ) - + view.loadVideo(config: config) } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 69c6d2723..1dd2555f0 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -15,7 +15,12 @@ struct VideoLoadConfig { var initialSubtitleId: Int? /// MPV audio track ID to select on start (1-based, nil to use default) var initialAudioId: Int? - + /// Cache/buffer settings + var cacheEnabled: String? // "auto", "yes", or "no" + var cacheSeconds: Int? // Seconds of video to buffer + var demuxerMaxBytes: Int? // Max cache size in MB + var demuxerMaxBackBytes: Int? // Max backward cache size in MB + init( url: URL, headers: [String: String]? = nil, @@ -23,7 +28,11 @@ struct VideoLoadConfig { startPosition: Double? = nil, autoplay: Bool = true, initialSubtitleId: Int? = nil, - initialAudioId: Int? = nil + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil ) { self.url = url self.headers = headers @@ -32,6 +41,10 @@ struct VideoLoadConfig { self.autoplay = autoplay self.initialSubtitleId = initialSubtitleId self.initialAudioId = initialAudioId + self.cacheEnabled = cacheEnabled + self.cacheSeconds = cacheSeconds + self.demuxerMaxBytes = demuxerMaxBytes + self.demuxerMaxBackBytes = demuxerMaxBackBytes } } @@ -151,13 +164,17 @@ class MpvPlayerView: ExpoView { startPosition: config.startPosition, externalSubtitles: config.externalSubtitles, initialSubtitleId: config.initialSubtitleId, - initialAudioId: config.initialAudioId + initialAudioId: config.initialAudioId, + cacheEnabled: config.cacheEnabled, + cacheSeconds: config.cacheSeconds, + demuxerMaxBytes: config.demuxerMaxBytes, + demuxerMaxBackBytes: config.demuxerMaxBackBytes ) - + if config.autoplay { play() } - + onLoad(["url": config.url.absoluteString]) } diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 23f860932..c700cb820 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -43,6 +43,17 @@ export type VideoSource = { initialSubtitleId?: number; /** MPV audio track ID to select on start (1-based) */ initialAudioId?: number; + /** MPV cache/buffer configuration */ + cacheConfig?: { + /** Whether caching is enabled: "auto" (default), "yes", or "no" */ + enabled?: "auto" | "yes" | "no"; + /** Seconds of video to buffer (default: 10, range: 5-120) */ + cacheSeconds?: number; + /** Maximum cache size in MB (default: 150, range: 50-500) */ + maxBytes?: number; + /** Maximum backward cache size in MB (default: 50, range: 25-200) */ + maxBackBytes?: number; + }; }; export type MpvPlayerViewProps = { diff --git a/translations/en.json b/translations/en.json index b3c6963f2..6e016a125 100644 --- a/translations/en.json +++ b/translations/en.json @@ -185,6 +185,16 @@ "rewind_length": "Rewind Length", "seconds_unit": "s" }, + "buffer": { + "title": "Buffer Settings", + "cache_mode": "Cache Mode", + "cache_auto": "Auto", + "cache_yes": "Enabled", + "cache_no": "Disabled", + "buffer_duration": "Buffer Duration", + "max_cache_size": "Max Cache Size", + "max_backward_cache": "Max Backward Cache" + }, "gesture_controls": { "gesture_controls_title": "Gesture Controls", "horizontal_swipe_skip": "Horizontal Swipe to Skip", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index aa4ac0c88..59aea1266 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -154,6 +154,9 @@ export enum AudioTranscodeMode { AllowAll = "passthrough", // Direct play all audio formats } +// MPV cache mode - controls how caching is enabled +export type MpvCacheMode = "auto" | "yes" | "no"; + export type Settings = { home?: Home | null; deviceProfile?: "Expo" | "Native" | "Old"; @@ -199,6 +202,11 @@ export type Settings = { mpvSubtitleAlignX?: "left" | "center" | "right"; mpvSubtitleAlignY?: "top" | "center" | "bottom"; mpvSubtitleFontSize?: number; + // MPV buffer/cache settings + mpvCacheEnabled?: MpvCacheMode; + mpvCacheSeconds?: number; + mpvDemuxerMaxBytes?: number; // MB + mpvDemuxerMaxBackBytes?: number; // MB // Gesture controls enableHorizontalSwipeSkip: boolean; enableLeftSideBrightnessSwipe: boolean; @@ -290,6 +298,11 @@ export const defaultValues: Settings = { mpvSubtitleAlignX: undefined, mpvSubtitleAlignY: undefined, mpvSubtitleFontSize: undefined, + // MPV buffer/cache defaults + mpvCacheEnabled: "auto", + mpvCacheSeconds: 10, + mpvDemuxerMaxBytes: 150, // MB + mpvDemuxerMaxBackBytes: 50, // MB // Gesture controls enableHorizontalSwipeSkip: true, enableLeftSideBrightnessSwipe: true, From 43ca6e9148a8f45d6412a13b4bf32127a02fe948 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 20:50:16 +0100 Subject: [PATCH 128/309] fix(player): disable subtitle scaling with window on iOS --- modules/mpv-player/ios/MPVLayerRenderer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index a6f00fd1d..52b3afec3 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -220,6 +220,8 @@ final class MPVLayerRenderer { #endif // Subtitle and audio settings + checkError(mpv_set_option_string(mpv, "sub-scale-with-window", "no")) + checkError(mpv_set_option_string(mpv, "sub-use-margins", "no")) checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) From 62a099e82f0c9cc7ec30d0b5c161b4f70d370021 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 21:01:25 +0100 Subject: [PATCH 129/309] refactor(player): consolidate subtitle settings to use mpvSubtitleScale only --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 20 ++--------- app/(auth)/player/direct-player.tsx | 9 ----- app/(auth)/tv-subtitle-modal.tsx | 4 +-- components/settings/MpvSubtitleSettings.tsx | 12 ------- components/settings/SubtitleToggles.tsx | 8 ++--- .../controls/dropdown/DropdownView.tsx | 34 ++++++++++--------- 6 files changed, 26 insertions(+), 61 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 84cd7da2b..b9a0849c2 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -284,26 +284,10 @@ export default function SettingsTV() { /> { - const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1); - updateSettings({ subtitleSize: Math.round(newValue * 100) }); - }} - onIncrease={() => { - const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1); - updateSettings({ subtitleSize: Math.round(newValue * 100) }); - }} - formatValue={(v) => `${v.toFixed(1)}x`} - /> - - {/* MPV Subtitles Section */} - - { const newValue = Math.max( - 0.5, + 0.1, (settings.mpvSubtitleScale ?? 1.0) - 0.1, ); updateSettings({ @@ -312,7 +296,7 @@ export default function SettingsTV() { }} onIncrease={() => { const newValue = Math.min( - 2.0, + 3.0, (settings.mpvSubtitleScale ?? 1.0) + 0.1, ); updateSettings({ diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 17fa7506e..3155849ba 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -1039,15 +1039,6 @@ export default function page() { if (settings.mpvSubtitleAlignY !== undefined) { await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); } - if (settings.mpvSubtitleFontSize !== undefined) { - await videoRef.current?.setSubtitleFontSize?.( - settings.mpvSubtitleFontSize, - ); - } - // Apply subtitle size from general settings - if (settings.subtitleSize) { - await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize); - } }; applySubtitleSettings(); diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 7167f7ba5..1745beed7 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -905,8 +905,8 @@ export default function TVSubtitleModal() { `${v.toFixed(1)}x`} onChange={(newValue) => { diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx index 0ceae68bb..c715cbe7b 100644 --- a/components/settings/MpvSubtitleSettings.tsx +++ b/components/settings/MpvSubtitleSettings.tsx @@ -68,18 +68,6 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => { } > - - - updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) - } - /> - - = ({ ...props }) => { disabled={pluginSettings?.subtitleSize?.locked} > - updateSettings({ subtitleSize: Math.round(value * 100) }) + updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) } /> diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 5b631ec48..7b6713b39 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -15,16 +15,18 @@ import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; -// Subtitle size presets (stored as scale * 100, so 1.0 = 100) -const SUBTITLE_SIZE_PRESETS = [ - { label: "0.5", value: 50 }, - { label: "0.6", value: 60 }, - { label: "0.7", value: 70 }, - { label: "0.8", value: 80 }, - { label: "0.9", value: 90 }, - { label: "1.0", value: 100 }, - { label: "1.1", value: 110 }, - { label: "1.2", value: 120 }, +// Subtitle scale presets (direct multiplier values) +const SUBTITLE_SCALE_PRESETS = [ + { label: "0.1x", value: 0.1 }, + { label: "0.25x", value: 0.25 }, + { label: "0.5x", value: 0.5 }, + { label: "0.75x", value: 0.75 }, + { label: "1.0x", value: 1.0 }, + { label: "1.25x", value: 1.25 }, + { label: "1.5x", value: 1.5 }, + { label: "2.0x", value: 2.0 }, + { label: "2.5x", value: 2.5 }, + { label: "3.0x", value: 3.0 }, ] as const; interface DropdownViewProps { @@ -124,15 +126,15 @@ const DropdownView = ({ })), }); - // Subtitle Size Section + // Subtitle Scale Section groups.push({ - title: "Subtitle Size", - options: SUBTITLE_SIZE_PRESETS.map((preset) => ({ + title: "Subtitle Scale", + options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ type: "radio" as const, label: preset.label, value: preset.value.toString(), - selected: settings.subtitleSize === preset.value, - onPress: () => updateSettings({ subtitleSize: preset.value }), + selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value, + onPress: () => updateSettings({ mpvSubtitleScale: preset.value }), })), }); } @@ -190,7 +192,7 @@ const DropdownView = ({ audioTracksKey, subtitleIndex, audioIndex, - settings.subtitleSize, + settings.mpvSubtitleScale, updateSettings, playbackSpeed, setPlaybackSpeed, From 9763c260460f6612f7967db45f85329c97c163ec Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 19:40:18 +0100 Subject: [PATCH 130/309] fix(player): handle remote streams and live tv containers correctly --- app/(auth)/player/direct-player.tsx | 30 +++++++++++++++++++++------- utils/jellyfin/media/getStreamUrl.ts | 24 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 3155849ba..00f3e74fb 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -262,6 +262,7 @@ export default function page() { mediaSource: MediaSourceInfo; sessionId: string; url: string; + requiredHttpHeaders?: Record; } const [stream, setStream] = useState(null); @@ -324,7 +325,7 @@ export default function page() { deviceProfile: generateDeviceProfile(), }); if (!res) return null; - const { mediaSource, sessionId, url } = res; + const { mediaSource, sessionId, url, requiredHttpHeaders } = res; if (!sessionId || !mediaSource || !url) { Alert.alert( @@ -333,7 +334,7 @@ export default function page() { ); return null; } - result = { mediaSource, sessionId, url }; + result = { mediaSource, sessionId, url, requiredHttpHeaders }; } setStream(result); setStreamStatus({ isLoading: false, isError: false }); @@ -601,17 +602,32 @@ export default function page() { source.externalSubtitles = externalSubs; } - // Add auth headers only for online streaming (not for local file:// URLs) - if (!offline && api?.accessToken) { - source.headers = { - Authorization: `MediaBrowser Token="${api.accessToken}"`, - }; + // Add headers for online streaming (not for local file:// URLs) + if (!offline) { + const headers: Record = {}; + const isRemoteStream = + mediaSource?.IsRemote && mediaSource?.Protocol === "Http"; + + // Add auth header only for Jellyfin API requests (not for external/remote streams) + if (api?.accessToken && !isRemoteStream) { + headers.Authorization = `MediaBrowser Token="${api.accessToken}"`; + } + + // Add any required headers from the media source (e.g., for external/remote streams) + if (stream?.requiredHttpHeaders) { + Object.assign(headers, stream.requiredHttpHeaders); + } + + if (Object.keys(headers).length > 0) { + source.headers = headers; + } } return source; }, [ stream?.url, stream?.mediaSource, + stream?.requiredHttpHeaders, item?.UserData?.PlaybackPositionTicks, playbackPositionFromUrl, api?.basePath, diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index c21247201..355727184 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -12,6 +12,7 @@ interface StreamResult { url: string; sessionId: string | null; mediaSource: MediaSourceInfo | undefined; + requiredHttpHeaders?: Record; } /** @@ -50,10 +51,24 @@ const getPlaybackUrl = ( return `${api.basePath}${transcodeUrl}`; } + // Handle remote/external streams (like live TV with external URLs) + // These have Protocol "Http" and IsRemote true, with the actual URL in Path + if ( + mediaSource?.IsRemote && + mediaSource?.Protocol === "Http" && + mediaSource?.Path + ) { + console.log("Video is remote stream, using direct Path:", mediaSource.Path); + return mediaSource.Path; + } + // Fall back to direct play + // Use the mediaSource's actual container when available (important for live TV + // where the container may be ts/hls, not mp4) + const container = params.container || mediaSource?.Container || "mp4"; const streamParams = new URLSearchParams({ static: params.static || "true", - container: params.container || "mp4", + container, mediaSourceId: mediaSource?.Id || "", subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "", audioStreamIndex: params.audioStreamIndex?.toString() || "", @@ -163,6 +178,7 @@ export const getStreamUrl = async ({ url: string | null; sessionId: string | null; mediaSource: MediaSourceInfo | undefined; + requiredHttpHeaders?: Record; } | null> => { if (!api || !userId || !item?.Id) { console.warn("Missing required parameters for getStreamUrl"); @@ -210,6 +226,9 @@ export const getStreamUrl = async ({ url, sessionId: sessionId || null, mediaSource, + requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as + | Record + | undefined, }; } @@ -254,6 +273,9 @@ export const getStreamUrl = async ({ url, sessionId: sessionId || null, mediaSource, + requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as + | Record + | undefined, }; }; From 603395815820336d300e3c5e978e48c032b331ed Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 19:45:51 +0100 Subject: [PATCH 131/309] refactor(claude): restructure learned facts into individual files with compressed index --- .claude/commands/reflect.md | 61 ++++++++++++++----- .claude/learned-facts.md | 9 ++- .../learned-facts/header-button-locations.md | 9 +++ .../intro-modal-trigger-location.md | 9 +++ .../introsheet-rendering-location.md | 9 +++ .../learned-facts/macos-header-buttons-fix.md | 9 +++ .claude/learned-facts/mark-as-played-flow.md | 9 +++ ...mpv-avfoundation-composite-osd-ordering.md | 9 +++ .../mpv-tvos-player-exit-freeze.md | 9 +++ .../native-bottom-tabs-userouter-conflict.md | 9 +++ .../native-swiftui-view-sizing.md | 9 +++ ...form-specific-file-suffix-does-not-work.md | 9 +++ .../stack-screen-header-configuration.md | 9 +++ .../streamystats-components-location.md | 9 +++ .claude/learned-facts/tab-folder-naming.md | 9 +++ .../thread-safe-state-for-stop-flags.md | 9 +++ .../learned-facts/tv-grid-layout-pattern.md | 9 +++ .../tv-horizontal-padding-standard.md | 9 +++ .../tv-modals-must-use-navigation-pattern.md | 9 +++ ...-network-aware-query-client-limitations.md | 9 +++ CLAUDE.md | 34 ++++++++++- 21 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 .claude/learned-facts/header-button-locations.md create mode 100644 .claude/learned-facts/intro-modal-trigger-location.md create mode 100644 .claude/learned-facts/introsheet-rendering-location.md create mode 100644 .claude/learned-facts/macos-header-buttons-fix.md create mode 100644 .claude/learned-facts/mark-as-played-flow.md create mode 100644 .claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md create mode 100644 .claude/learned-facts/mpv-tvos-player-exit-freeze.md create mode 100644 .claude/learned-facts/native-bottom-tabs-userouter-conflict.md create mode 100644 .claude/learned-facts/native-swiftui-view-sizing.md create mode 100644 .claude/learned-facts/platform-specific-file-suffix-does-not-work.md create mode 100644 .claude/learned-facts/stack-screen-header-configuration.md create mode 100644 .claude/learned-facts/streamystats-components-location.md create mode 100644 .claude/learned-facts/tab-folder-naming.md create mode 100644 .claude/learned-facts/thread-safe-state-for-stop-flags.md create mode 100644 .claude/learned-facts/tv-grid-layout-pattern.md create mode 100644 .claude/learned-facts/tv-horizontal-padding-standard.md create mode 100644 .claude/learned-facts/tv-modals-must-use-navigation-pattern.md create mode 100644 .claude/learned-facts/use-network-aware-query-client-limitations.md diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md index 2ee234799..deedf8d4f 100644 --- a/.claude/commands/reflect.md +++ b/.claude/commands/reflect.md @@ -12,26 +12,59 @@ Analyze the current conversation to extract useful facts that should be remember ## Instructions -1. Read the existing facts file at `.claude/learned-facts.md` +1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded 2. Review this conversation for learnings worth preserving 3. For each new fact: - - Write it concisely (1-2 sentences max) - - Include context for why it matters - - Add today's date + - Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below + - Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md` 4. Skip facts that duplicate existing entries -5. Append new facts to `.claude/learned-facts.md` +5. If a new category is needed, add it to the index in `CLAUDE.md` -## Fact Format +## Fact File Template -Use this format for each fact: -``` -- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_ +Create each file at `.claude/learned-facts/[kebab-case-name].md`: + +```markdown +# [Title] + +**Date**: YYYY-MM-DD +**Category**: navigation | tv | native-modules | state-management | ui +**Key files**: `relevant/paths.ts` + +## Detail + +[Full description of the fact, including context for why it matters] ``` -## Example Facts +## Index Entry Format -- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_ -- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_ -- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_ +Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`: -After updating the file, summarize what facts you added (or note if nothing new was learned this session). +``` +- `kebab-case-name` | Brief one-line summary of the fact +``` + +Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform + +## Example + +File `.claude/learned-facts/state-management-pattern.md`: +```markdown +# State Management Pattern + +**Date**: 2025-01-09 +**Category**: state-management +**Key files**: `utils/atoms/` + +## Detail + +Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`. +``` + +Index entry in `CLAUDE.md`: +``` +State/Data: +- `state-management-pattern` | Use Jotai atoms for global state, not React Context +``` + +After updating, summarize what facts you added (or note if nothing new was learned this session). diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index e11daa072..ab5d6eecc 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -1,8 +1,11 @@ -# Learned Facts +# Learned Facts (DEPRECATED) -This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions. +> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`. +> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index". +> New facts should be added as individual files using the `/reflect` command. +> This file is kept for reference only and is no longer auto-imported. -This file is auto-imported into CLAUDE.md and loaded at the start of each session. +This file previously contained facts about the codebase learned from past sessions. ## Facts diff --git a/.claude/learned-facts/header-button-locations.md b/.claude/learned-facts/header-button-locations.md new file mode 100644 index 000000000..269b51f15 --- /dev/null +++ b/.claude/learned-facts/header-button-locations.md @@ -0,0 +1,9 @@ +# Header Button Locations + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx` + +## Detail + +Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. diff --git a/.claude/learned-facts/intro-modal-trigger-location.md b/.claude/learned-facts/intro-modal-trigger-location.md new file mode 100644 index 000000000..4409db06a --- /dev/null +++ b/.claude/learned-facts/intro-modal-trigger-location.md @@ -0,0 +1,9 @@ +# Intro Modal Trigger Location + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx` + +## Detail + +The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. diff --git a/.claude/learned-facts/introsheet-rendering-location.md b/.claude/learned-facts/introsheet-rendering-location.md new file mode 100644 index 000000000..b9575cd72 --- /dev/null +++ b/.claude/learned-facts/introsheet-rendering-location.md @@ -0,0 +1,9 @@ +# IntroSheet Rendering Location + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet` + +## Detail + +The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. diff --git a/.claude/learned-facts/macos-header-buttons-fix.md b/.claude/learned-facts/macos-header-buttons-fix.md new file mode 100644 index 000000000..45d5f31a5 --- /dev/null +++ b/.claude/learned-facts/macos-header-buttons-fix.md @@ -0,0 +1,9 @@ +# macOS Header Buttons Fix + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx` + +## Detail + +Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. diff --git a/.claude/learned-facts/mark-as-played-flow.md b/.claude/learned-facts/mark-as-played-flow.md new file mode 100644 index 000000000..48603cd0c --- /dev/null +++ b/.claude/learned-facts/mark-as-played-flow.md @@ -0,0 +1,9 @@ +# Mark as Played Flow + +**Date**: 2026-01-10 +**Category**: state-management +**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts` + +## Detail + +The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. diff --git a/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md new file mode 100644 index 000000000..418f862ae --- /dev/null +++ b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md @@ -0,0 +1,9 @@ +# MPV avfoundation-composite-osd Ordering + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). diff --git a/.claude/learned-facts/mpv-tvos-player-exit-freeze.md b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md new file mode 100644 index 000000000..7dfb20178 --- /dev/null +++ b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md @@ -0,0 +1,9 @@ +# MPV tvOS Player Exit Freeze + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. diff --git a/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md new file mode 100644 index 000000000..eda49ef05 --- /dev/null +++ b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md @@ -0,0 +1,9 @@ +# Native Bottom Tabs + useRouter Conflict + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `providers/`, `app/_layout.tsx` + +## Detail + +When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. diff --git a/.claude/learned-facts/native-swiftui-view-sizing.md b/.claude/learned-facts/native-swiftui-view-sizing.md new file mode 100644 index 000000000..f36a18374 --- /dev/null +++ b/.claude/learned-facts/native-swiftui-view-sizing.md @@ -0,0 +1,9 @@ +# Native SwiftUI View Sizing + +**Date**: 2026-01-25 +**Category**: native-modules +**Key files**: `modules/` + +## Detail + +When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. diff --git a/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md new file mode 100644 index 000000000..d52dca9b9 --- /dev/null +++ b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md @@ -0,0 +1,9 @@ +# Platform-Specific File Suffix (.tv.tsx) Does NOT Work + +**Date**: 2026-01-26 +**Category**: tv +**Key files**: `app/`, `components/` + +## Detail + +The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. diff --git a/.claude/learned-facts/stack-screen-header-configuration.md b/.claude/learned-facts/stack-screen-header-configuration.md new file mode 100644 index 000000000..24ca01fcd --- /dev/null +++ b/.claude/learned-facts/stack-screen-header-configuration.md @@ -0,0 +1,9 @@ +# Stack Screen Header Configuration + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx` + +## Detail + +Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. diff --git a/.claude/learned-facts/streamystats-components-location.md b/.claude/learned-facts/streamystats-components-location.md new file mode 100644 index 000000000..41652a528 --- /dev/null +++ b/.claude/learned-facts/streamystats-components-location.md @@ -0,0 +1,9 @@ +# Streamystats Components Location + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx` + +## Detail + +Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. diff --git a/.claude/learned-facts/tab-folder-naming.md b/.claude/learned-facts/tab-folder-naming.md new file mode 100644 index 000000000..7663a6099 --- /dev/null +++ b/.claude/learned-facts/tab-folder-naming.md @@ -0,0 +1,9 @@ +# Tab Folder Naming + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `app/(auth)/(tabs)/` + +## Detail + +The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. diff --git a/.claude/learned-facts/thread-safe-state-for-stop-flags.md b/.claude/learned-facts/thread-safe-state-for-stop-flags.md new file mode 100644 index 000000000..eaa0d84d0 --- /dev/null +++ b/.claude/learned-facts/thread-safe-state-for-stop-flags.md @@ -0,0 +1,9 @@ +# Thread-Safe State for Stop Flags + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. diff --git a/.claude/learned-facts/tv-grid-layout-pattern.md b/.claude/learned-facts/tv-grid-layout-pattern.md new file mode 100644 index 000000000..6f9b234a2 --- /dev/null +++ b/.claude/learned-facts/tv-grid-layout-pattern.md @@ -0,0 +1,9 @@ +# TV Grid Layout Pattern + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/tv/` + +## Detail + +For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. diff --git a/.claude/learned-facts/tv-horizontal-padding-standard.md b/.claude/learned-facts/tv-horizontal-padding-standard.md new file mode 100644 index 000000000..e9ddc0c88 --- /dev/null +++ b/.claude/learned-facts/tv-horizontal-padding-standard.md @@ -0,0 +1,9 @@ +# TV Horizontal Padding Standard + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/tv/`, `app/(auth)/(tabs)/` + +## Detail + +TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. diff --git a/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md new file mode 100644 index 000000000..c6c837d5a --- /dev/null +++ b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md @@ -0,0 +1,9 @@ +# TV Modals Must Use Navigation Pattern + +**Date**: 2026-01-24 +**Category**: tv +**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx` + +## Detail + +On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. diff --git a/.claude/learned-facts/use-network-aware-query-client-limitations.md b/.claude/learned-facts/use-network-aware-query-client-limitations.md new file mode 100644 index 000000000..36e8f2d82 --- /dev/null +++ b/.claude/learned-facts/use-network-aware-query-client-limitations.md @@ -0,0 +1,9 @@ +# useNetworkAwareQueryClient Limitations + +**Date**: 2026-01-10 +**Category**: state-management +**Key files**: `hooks/useNetworkAwareQueryClient.ts` + +## Detail + +The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. diff --git a/CLAUDE.md b/CLAUDE.md index 9fd32e751..0c037d420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,39 @@ # CLAUDE.md -@.claude/learned-facts.md - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Learned Facts Index + +IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions. + +Navigation: +- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import +- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks +- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx +- `tab-folder-naming` | Use underscore prefix: (_home) not (home) + +UI/Headers: +- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity +- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc. +- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button + +State/Data: +- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries +- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates + +Native Modules: +- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global() +- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options +- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async) +- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView + +TV Platform: +- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals +- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns +- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20 +- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx +- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering + ## Project Overview Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration. From 2780b902e9dd60f6e916f9b1ee806c2a76386066 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 19:47:47 +0100 Subject: [PATCH 132/309] feat(tv): add favorite button to series detail page --- components/series/TVSeriesPage.tsx | 3 +++ components/tv/TVFavoriteButton.tsx | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 72d26c44a..ef16f0f68 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -29,6 +29,7 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { seasonIndexAtom } from "@/components/series/SeasonPicker"; import { TVEpisodeList } from "@/components/series/TVEpisodeList"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; +import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; @@ -577,6 +578,8 @@ export const TVSeriesPage: React.FC = ({ + + {seasons.length > 1 && ( = ({ item }) => { +export const TVFavoriteButton: React.FC = ({ + item, + disabled, +}) => { const { isFavorite, toggleFavorite } = useFavorite(item); return ( - + Date: Wed, 28 Jan 2026 19:50:12 +0100 Subject: [PATCH 133/309] refactor(tv): swap poster and content layout in series page --- components/series/TVSeriesPage.tsx | 56 +++++++++++++++--------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index ef16f0f68..ff533f028 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -509,40 +509,14 @@ export const TVSeriesPage: React.FC = ({ }} showsVerticalScrollIndicator={false} > - {/* Top section - Poster + Content */} + {/* Top section - Content + Poster */} - {/* Left side - Poster */} - - - - - - - {/* Right side - Content */} + {/* Left side - Content */} @@ -589,6 +563,32 @@ export const TVSeriesPage: React.FC = ({ )} + + {/* Right side - Poster */} + + + + + {/* Episodes section */} From 74114893e5b0dd14946bdc85a31211609e29367b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 19:57:54 +0100 Subject: [PATCH 134/309] fix(tv): use router.replace for episode navigation to prevent page stacking --- components/ItemContent.tv.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 0494f1081..ae91ca6ac 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -43,6 +43,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -78,6 +79,7 @@ export const ItemContentTV: React.FC = React.memo( const { settings } = useSettings(); const insets = useSafeAreaInsets(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -479,7 +481,7 @@ export const ItemContentTV: React.FC = React.memo( const handleEpisodePress = useCallback( (episode: BaseItemDto) => { const navigation = getItemNavigation(episode, "(home)"); - router.push(navigation as any); + router.replace(navigation as any); }, [router], ); @@ -820,6 +822,7 @@ export const ItemContentTV: React.FC = React.memo( episodes={seasonEpisodes} currentEpisodeId={item.Id} onEpisodePress={handleEpisodePress} + onEpisodeLongPress={showItemActions} firstEpisodeRefSetter={setFirstEpisodeRef} /> From 8dcd4c40f93c60cafbe4d1f0b20d5c8efcbbcf44 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 20:21:56 +0100 Subject: [PATCH 135/309] chore: remove debug console.log statements from providers and layout --- app/_layout.tsx | 5 +---- providers/ServerUrlProvider.tsx | 15 --------------- providers/WebSocketProvider.tsx | 1 - 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 4599bcc48..ad2ca9917 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -253,22 +253,19 @@ function Layout() { deviceId: getOrSetDeviceId(), userId: user.Id, }) - .then((_) => console.log("Posted expo push token")) .catch((_) => writeErrorLog("Failed to push expo push token to plugin"), ); - } else console.log("No token available"); + } }, [api, expoPushToken, user]); const registerNotifications = useCallback(async () => { if (Platform.OS === "android") { - console.log("Setting android notification channel 'default'"); await Notifications?.setNotificationChannelAsync("default", { name: "default", }); // Create dedicated channel for download notifications - console.log("Setting android notification channel 'downloads'"); await Notifications?.setNotificationChannelAsync("downloads", { name: "Downloads", importance: Notifications.AndroidImportance.DEFAULT, diff --git a/providers/ServerUrlProvider.tsx b/providers/ServerUrlProvider.tsx index 17ce17734..f73eb907f 100644 --- a/providers/ServerUrlProvider.tsx +++ b/providers/ServerUrlProvider.tsx @@ -34,13 +34,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { const { switchServerUrl } = useJellyfin(); const { ssid, permissionStatus } = useWifiSSID(); - console.log( - "[ServerUrlProvider] ssid:", - ssid, - "permissionStatus:", - permissionStatus, - ); - const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false); const [effectiveServerUrl, setEffectiveServerUrl] = useState( null, @@ -76,13 +69,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl; - console.log("[ServerUrlProvider] evaluateAndSwitchUrl:", { - ssid, - shouldUseLocal, - targetUrl, - config, - }); - switchServerUrl(targetUrl); setIsUsingLocalUrl(shouldUseLocal); setEffectiveServerUrl(targetUrl); @@ -90,7 +76,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { // Manual refresh function for when config changes const refreshUrlState = useCallback(() => { - console.log("[ServerUrlProvider] refreshUrlState called"); evaluateAndSwitchUrl(); }, [evaluateAndSwitchUrl]); diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 78d3c3c83..bb9d1d1ff 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -66,7 +66,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const reconnectDelay = 10000; newWebSocket.onopen = () => { - console.log("WebSocket connection opened"); setIsConnected(true); reconnectAttemptsRef.current = 0; keepAliveInterval = setInterval(() => { From 2ff96259039fd317b282540f7e67671db5dbca29 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 20:36:57 +0100 Subject: [PATCH 136/309] feat(tv): add long-press mark as watched action using alert dialog --- .../collections/[collectionId].tsx | 9 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 9 +- app/(auth)/(tabs)/(search)/index.tsx | 3 + .../(tabs)/(watchlists)/[watchlistId].tsx | 5 +- components/ItemContent.tv.tsx | 2 + components/home/Home.tv.tsx | 8 +- .../InfiniteScrollingCollectionList.tv.tsx | 4 + .../StreamystatsPromotedWatchlists.tv.tsx | 5 +- .../home/StreamystatsRecommendations.tv.tsx | 5 +- components/home/TVHeroCarousel.tsx | 14 +++- components/persons/TVActorPage.tsx | 16 +++- components/search/TVSearchPage.tsx | 3 + components/search/TVSearchSection.tsx | 6 ++ components/series/TVEpisodeCard.tsx | 3 + components/series/TVEpisodeList.tsx | 6 ++ components/series/TVSeriesPage.tsx | 3 + components/tv/TVFocusablePoster.tsx | 3 + components/tv/TVPlayedButton.tsx | 33 ++++++++ components/tv/index.ts | 2 + hooks/useTVItemActionModal.ts | 82 +++++++++++++++++++ translations/en.json | 4 +- 21 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 components/tv/TVPlayedButton.tsx create mode 100644 hooks/useTVItemActionModal.ts diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index 1a6ca0b7c..749d8508a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -36,6 +36,7 @@ import { } from "@/components/tv"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -65,6 +66,7 @@ const page: React.FC = () => { const navigation = useNavigation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + const { showItemActions } = useTVItemActionModal(); const { width: screenWidth } = useWindowDimensions(); const [orientation, _setOrientation] = useState( ScreenOrientation.Orientation.PORTRAIT_UP, @@ -294,7 +296,10 @@ const page: React.FC = () => { width: posterSizes.poster, }} > - + showItemActions(item)} + > {item.Type === "Movie" && } {(item.Type === "Series" || item.Type === "Episode") && ( @@ -307,7 +312,7 @@ const page: React.FC = () => { ); }, - [router], + [router, showItemActions], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 02bc671ef..9fe9733c5 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -44,6 +44,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -108,6 +109,7 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + const { showItemActions } = useTVItemActionModal(); // TV Filter queries const { data: tvGenreOptions } = useQuery({ @@ -412,7 +414,10 @@ const Page = () => { width: posterSizes.poster, }} > - + showItemActions(item)} + > {item.Type === "Movie" && } {(item.Type === "Series" || item.Type === "Episode") && ( @@ -425,7 +430,7 @@ const Page = () => { ); }, - [router], + [router, showItemActions], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 831d96c1e..5c4868660 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { TVSearchPage } from "@/components/search/TVSearchPage"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -69,6 +70,7 @@ export default function search() { const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(search)"; @@ -607,6 +609,7 @@ export default function search() { loading={loading} noResults={noResults} onItemPress={handleItemPress} + onItemLongPress={showItemActions} searchType={searchType} setSearchType={setSearchType} showDiscover={!!jellyseerrApi} diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 0adee9733..07b03b17d 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -31,6 +31,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useDeleteWatchlist, useRemoveFromWatchlist, @@ -75,6 +76,7 @@ export default function WatchlistDetailScreen() { const posterSizes = useScaledTVPosterSizes(); const { t } = useTranslation(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); @@ -211,6 +213,7 @@ export default function WatchlistDetailScreen() { > showItemActions(item)} hasTVPreferredFocus={index === 0} > {item.Type === "Movie" && } @@ -222,7 +225,7 @@ export default function WatchlistDetailScreen() { ); }, - [router, typography], + [router, showItemActions, typography], ); const renderItem = useCallback( diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index ae91ca6ac..acc2f5830 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -33,6 +33,7 @@ import { TVFavoriteButton, TVMetadataBadges, TVOptionButton, + TVPlayedButton, TVProgressBar, TVRefreshButton, TVSeriesNavigation, @@ -646,6 +647,7 @@ export const ItemContentTV: React.FC = React.memo( + diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 1b9ee9ddf..770597c21 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -36,6 +36,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -77,6 +78,7 @@ export const Home = () => { retryCheck, } = useNetworkStatus(); const _invalidateCache = useInvalidatePlaybackProgressCache(); + const { showItemActions } = useTVItemActionModal(); const [loadedSections, setLoadedSections] = useState>(new Set()); // Dynamic backdrop state with debounce @@ -745,7 +747,11 @@ export const Home = () => { > {/* Hero Carousel - Apple TV+ style featured content */} {showHero && heroItems && ( - + )} = ({ const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; @@ -362,6 +364,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} @@ -381,6 +384,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ isFirstSection, itemWidth, handleItemPress, + showItemActions, handleItemFocus, handleItemBlur, typography, diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 1c5e69a46..07ba09b21 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -17,6 +17,7 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; @@ -70,6 +71,7 @@ const WatchlistSection: React.FC = ({ const user = useAtomValue(userAtom); const { settings } = useSettings(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; @@ -142,6 +144,7 @@ const WatchlistSection: React.FC = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > @@ -152,7 +155,7 @@ const WatchlistSection: React.FC = ({ ); }, - [handleItemPress, onItemFocus, typography], + [handleItemPress, showItemActions, onItemFocus, typography], ); if (!isLoading && (!items || items.length === 0)) return null; diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index 966407736..c8a9cf0bd 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -17,6 +17,7 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; @@ -74,6 +75,7 @@ export const StreamystatsRecommendations: React.FC = ({ const user = useAtomValue(userAtom); const { settings } = useSettings(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; @@ -203,6 +205,7 @@ export const StreamystatsRecommendations: React.FC = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > @@ -213,7 +216,7 @@ export const StreamystatsRecommendations: React.FC = ({ ); }, - [handleItemPress, onItemFocus, typography], + [handleItemPress, showItemActions, onItemFocus, typography], ); if (!streamyStatsEnabled) return null; diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 9293789e9..527bd74d5 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -43,6 +43,7 @@ const CARD_PADDING = 60; interface TVHeroCarouselProps { items: BaseItemDto[]; onItemFocus?: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; } interface HeroCardProps { @@ -51,10 +52,11 @@ interface HeroCardProps { cardWidth: number; onFocus: (item: BaseItemDto) => void; onPress: (item: BaseItemDto) => void; + onLongPress?: (item: BaseItemDto) => void; } const HeroCard: React.FC = React.memo( - ({ item, isFirst, cardWidth, onFocus, onPress }) => { + ({ item, isFirst, cardWidth, onFocus, onPress, onLongPress }) => { const api = useAtomValue(apiAtom); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -113,11 +115,16 @@ const HeroCard: React.FC = React.memo( onPress(item); }, [onPress, item]); + const handleLongPress = useCallback(() => { + onLongPress?.(item); + }, [onLongPress, item]); + // Use glass poster for tvOS 26+ if (useGlass) { return ( = React.memo( return ( = ({ items, onItemFocus, + onItemLongPress, }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -359,9 +368,10 @@ export const TVHeroCarousel: React.FC = ({ cardWidth={posterSizes.heroCard} onFocus={handleCardFocus} onPress={handleCardPress} + onLongPress={onItemLongPress} /> ), - [handleCardFocus, handleCardPress, posterSizes.heroCard], + [handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard], ); // Memoize keyExtractor diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index 4f543be83..eacfd7b1c 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -31,6 +31,7 @@ import { Loader } from "@/components/Loader"; import MoviePoster from "@/components/posters/MoviePoster.tv"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; @@ -47,10 +48,18 @@ const SCALE_PADDING = 20; const TVFocusablePoster: React.FC<{ children: React.ReactNode; onPress: () => void; + onLongPress?: () => void; hasTVPreferredFocus?: boolean; onFocus?: () => void; onBlur?: () => void; -}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => { +}> = ({ + children, + onPress, + onLongPress, + hasTVPreferredFocus, + onFocus, + onBlur, +}) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -65,6 +74,7 @@ const TVFocusablePoster: React.FC<{ return ( { setFocused(true); animateTo(1.05); @@ -100,6 +110,7 @@ export const TVActorPage: React.FC = ({ personId }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; const posterSizes = useScaledTVPosterSizes(); @@ -292,6 +303,7 @@ export const TVActorPage: React.FC = ({ personId }) => { handleItemPress(filmItem)} + onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={isFirstSection && index === 0} > @@ -304,7 +316,7 @@ export const TVActorPage: React.FC = ({ personId }) => { ), - [handleItemPress], + [handleItemPress, showItemActions], ); if (isLoadingActor) { diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 79d7c3790..feba7c2d1 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -106,6 +106,7 @@ interface TVSearchPageProps { loading: boolean; noResults: boolean; onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; // Jellyseerr/Discover props searchType: SearchType; setSearchType: (type: SearchType) => void; @@ -138,6 +139,7 @@ export const TVSearchPage: React.FC = ({ loading, noResults, onItemPress, + onItemLongPress, searchType, setSearchType, showDiscover, @@ -273,6 +275,7 @@ export const TVSearchPage: React.FC = ({ orientation={section.orientation || "vertical"} isFirstSection={index === 0} onItemPress={onItemPress} + onItemLongPress={onItemLongPress} imageUrlGetter={ ["artists", "albums", "songs", "playlists"].includes( section.key, diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index f9eca844e..310da15e6 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -143,6 +143,7 @@ interface TVSearchSectionProps extends ViewProps { disabled?: boolean; isFirstSection?: boolean; onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; imageUrlGetter?: (item: BaseItemDto) => string | undefined; } @@ -153,6 +154,7 @@ export const TVSearchSection: React.FC = ({ disabled = false, isFirstSection = false, onItemPress, + onItemLongPress, imageUrlGetter, ...props }) => { @@ -328,6 +330,9 @@ export const TVSearchSection: React.FC = ({ onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } hasTVPreferredFocus={isFirstItem && !disabled} onFocus={handleItemFocus} onBlur={handleItemBlur} @@ -344,6 +349,7 @@ export const TVSearchSection: React.FC = ({ isFirstSection, itemWidth, onItemPress, + onItemLongPress, handleItemFocus, handleItemBlur, disabled, diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 262dc3239..af1d8353a 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -26,6 +26,7 @@ interface TVEpisodeCardProps { /** Shows a "Now Playing" badge on the card */ isCurrent?: boolean; onPress: () => void; + onLongPress?: () => void; onFocus?: () => void; onBlur?: () => void; /** Setter function for the ref (for focus guide destinations) */ @@ -39,6 +40,7 @@ export const TVEpisodeCard: React.FC = ({ focusableWhenDisabled = false, isCurrent = false, onPress, + onLongPress, onFocus, onBlur, refSetter, @@ -123,6 +125,7 @@ export const TVEpisodeCard: React.FC = ({ > void; + /** Called when any episode is long-pressed */ + onEpisodeLongPress?: (episode: BaseItemDto) => void; /** Called when any episode gains focus */ onFocus?: () => void; /** Called when any episode loses focus */ @@ -35,6 +37,7 @@ export const TVEpisodeList: React.FC = ({ currentEpisodeId, disabled = false, onEpisodePress, + onEpisodeLongPress, onFocus, onBlur, scrollViewRef, @@ -79,6 +82,9 @@ export const TVEpisodeList: React.FC = ({ key={episode.Id} episode={episode} onPress={() => onEpisodePress(episode)} + onLongPress={ + onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined + } onFocus={onFocus} onBlur={onBlur} disabled={isCurrent || disabled} diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index ff533f028..e6440e1d0 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -32,6 +32,7 @@ import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -225,6 +226,7 @@ export const TVSeriesPage: React.FC = ({ const [user] = useAtom(userAtom); const { getDownloadedItems, downloadedItems } = useDownload(); const { showSeasonModal } = useTVSeriesSeasonModal(); + const { showItemActions } = useTVItemActionModal(); const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); const isSeasonModalVisible = seasonModalState !== null; @@ -625,6 +627,7 @@ export const TVSeriesPage: React.FC = ({ episodes={episodesForSeason} disabled={isSeasonModalVisible} onEpisodePress={handleEpisodePress} + onEpisodeLongPress={showItemActions} onFocus={handleEpisodeFocus} onBlur={handleEpisodeBlur} scrollViewRef={episodeListRef} diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx index 1a0b76538..337cbc2ab 100644 --- a/components/tv/TVFocusablePoster.tsx +++ b/components/tv/TVFocusablePoster.tsx @@ -10,6 +10,7 @@ import { export interface TVFocusablePosterProps { children: React.ReactNode; onPress: () => void; + onLongPress?: () => void; hasTVPreferredFocus?: boolean; glowColor?: "white" | "purple"; scaleAmount?: number; @@ -26,6 +27,7 @@ export interface TVFocusablePosterProps { export const TVFocusablePoster: React.FC = ({ children, onPress, + onLongPress, hasTVPreferredFocus = false, glowColor = "white", scaleAmount = 1.05, @@ -53,6 +55,7 @@ export const TVFocusablePoster: React.FC = ({ { setFocused(true); animateTo(scaleAmount); diff --git a/components/tv/TVPlayedButton.tsx b/components/tv/TVPlayedButton.tsx new file mode 100644 index 000000000..8ab8e4bb5 --- /dev/null +++ b/components/tv/TVPlayedButton.tsx @@ -0,0 +1,33 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; +import { TVButton } from "./TVButton"; + +export interface TVPlayedButtonProps { + item: BaseItemDto; + disabled?: boolean; +} + +export const TVPlayedButton: React.FC = ({ + item, + disabled, +}) => { + const isPlayed = item.UserData?.Played ?? false; + const toggle = useMarkAsPlayed([item]); + + return ( + toggle(!isPlayed)} + variant='glass' + square + disabled={disabled} + > + + + ); +}; diff --git a/components/tv/index.ts b/components/tv/index.ts index 14e71b2db..8352ba765 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -43,6 +43,8 @@ export type { TVOptionCardProps } from "./TVOptionCard"; export { TVOptionCard } from "./TVOptionCard"; export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector"; export { TVOptionSelector } from "./TVOptionSelector"; +export type { TVPlayedButtonProps } from "./TVPlayedButton"; +export { TVPlayedButton } from "./TVPlayedButton"; export type { TVProgressBarProps } from "./TVProgressBar"; export { TVProgressBar } from "./TVProgressBar"; export type { TVRefreshButtonProps } from "./TVRefreshButton"; diff --git a/hooks/useTVItemActionModal.ts b/hooks/useTVItemActionModal.ts new file mode 100644 index 000000000..3c547c0d6 --- /dev/null +++ b/hooks/useTVItemActionModal.ts @@ -0,0 +1,82 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-native"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; + +export const useTVItemActionModal = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); + const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); + + const showItemActions = useCallback( + (item: BaseItemDto) => { + const isPlayed = item.UserData?.Played ?? false; + const itemTitle = + item.Type === "Episode" + ? `${item.SeriesName} - ${item.Name}` + : (item.Name ?? ""); + + const actionLabel = isPlayed + ? t("item_card.mark_unplayed") + : t("item_card.mark_played"); + + Alert.alert(itemTitle, undefined, [ + { text: t("common.cancel"), style: "cancel" }, + { + text: actionLabel, + onPress: async () => { + if (!item.Id) return; + + // Optimistic update + queryClient.setQueriesData( + { queryKey: ["item", item.Id] }, + (old) => { + if (!old) return old; + return { + ...old, + UserData: { + ...old.UserData, + Played: !isPlayed, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + }, + }; + }, + ); + + try { + if (!isPlayed) { + await markItemPlayed(item.Id); + } else { + await markItemUnplayed(item.Id); + } + } catch { + // Revert on failure + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } finally { + await invalidatePlaybackProgressCache(); + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } + }, + }, + ]); + }, + [ + t, + queryClient, + markItemPlayed, + markItemUnplayed, + invalidatePlaybackProgressCache, + ], + ); + + return { showItemActions }; +}; diff --git a/translations/en.json b/translations/en.json index 6e016a125..bcc2c03d9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -717,7 +717,9 @@ "download_x_item": "Download {{item_count}} Items", "download_unwatched_only": "Unwatched Only", "download_button": "Download" - } + }, + "mark_played": "Mark as Watched", + "mark_unplayed": "Mark as Unwatched" }, "live_tv": { "next": "Next", From 409629bb4ad78ad888af14d5de8c5f7219e12b31 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 22:08:32 +0100 Subject: [PATCH 137/309] feat(tv): add background theme music playback --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 5 + bun.lock | 3 + components/ItemContent.tv.tsx | 4 + components/series/TVSeriesPage.tsx | 4 + components/tv/TVThemeMusicIndicator.tsx | 78 ++++++++++ components/tv/index.ts | 1 + hooks/useTVThemeMusic.ts | 179 +++++++++++++++++++++++ package.json | 1 + translations/en.json | 1 + utils/atoms/settings.ts | 2 + 10 files changed, 278 insertions(+) create mode 100644 components/tv/TVThemeMusicIndicator.tsx create mode 100644 hooks/useTVThemeMusic.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index b9a0849c2..3c92b8af7 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -506,6 +506,11 @@ export default function SettingsTV() { updateSettings({ showSeriesPosterOnEpisode: value }) } /> + updateSettings({ tvThemeMusicEnabled: value })} + /> {/* User Section */} = React.memo( const _itemColors = useImageColorsReturn({ item }); + // Auto-play theme music (handles fade in/out and cleanup) + useTVThemeMusic(item?.Id); + // State for first episode card ref (used for focus guide) const [_firstEpisodeRef, setFirstEpisodeRef] = useState(null); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index e6440e1d0..2dfe40441 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -34,6 +34,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; +import { useTVThemeMusic } from "@/hooks/useTVThemeMusic"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; @@ -230,6 +231,9 @@ export const TVSeriesPage: React.FC = ({ const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); const isSeasonModalVisible = seasonModalState !== null; + // Auto-play theme music (handles fade in/out and cleanup) + useTVThemeMusic(item.Id); + // Season state const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const selectedSeasonIndex = useMemo( diff --git a/components/tv/TVThemeMusicIndicator.tsx b/components/tv/TVThemeMusicIndicator.tsx new file mode 100644 index 000000000..93be4dfb2 --- /dev/null +++ b/components/tv/TVThemeMusicIndicator.tsx @@ -0,0 +1,78 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer"; + +interface TVThemeMusicIndicatorProps { + isPlaying: boolean; + isMuted: boolean; + hasThemeMusic: boolean; + onToggleMute: () => void; + disabled?: boolean; +} + +export const TVThemeMusicIndicator: React.FC = ({ + isPlaying, + isMuted, + hasThemeMusic, + onToggleMute, + disabled = false, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + if (!hasThemeMusic || !isPlaying) return null; + + return ( + { + setFocused(true); + animateTo(1.15); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + disabled={disabled} + focusable={!disabled} + > + + {isMuted ? ( + + ) : ( + + + + )} + + + ); +}; diff --git a/components/tv/index.ts b/components/tv/index.ts index 8352ba765..527ef22e1 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -59,6 +59,7 @@ export type { TVTabButtonProps } from "./TVTabButton"; export { TVTabButton } from "./TVTabButton"; export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails"; export { TVTechnicalDetails } from "./TVTechnicalDetails"; +export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator"; // Subtitle sheet components export type { TVTrackCardProps } from "./TVTrackCard"; export { TVTrackCard } from "./TVTrackCard"; diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts new file mode 100644 index 000000000..753b3e649 --- /dev/null +++ b/hooks/useTVThemeMusic.ts @@ -0,0 +1,179 @@ +import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import type { Audio as AudioType } from "expo-av"; +import { Audio } from "expo-av"; +import { useAtom } from "jotai"; +import { useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +const TARGET_VOLUME = 0.3; +const FADE_IN_DURATION = 2000; +const FADE_OUT_DURATION = 1000; +const FADE_STEP_MS = 50; + +/** + * Smoothly transitions audio volume from `from` to `to` over `duration` ms. + * Returns a cleanup function that cancels the fade. + */ +function fadeVolume( + sound: AudioType.Sound, + from: number, + to: number, + duration: number, +): { promise: Promise; cancel: () => void } { + let cancelled = false; + const cancel = () => { + cancelled = true; + }; + + const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS)); + const delta = (to - from) / steps; + + const promise = new Promise((resolve) => { + let current = from; + let step = 0; + + const tick = () => { + if (cancelled || step >= steps) { + if (!cancelled) { + sound.setVolumeAsync(to).catch(() => {}); + } + resolve(); + return; + } + step++; + current += delta; + sound + .setVolumeAsync(Math.max(0, Math.min(1, current))) + .catch(() => {}) + .then(() => { + if (!cancelled) { + setTimeout(tick, FADE_STEP_MS); + } else { + resolve(); + } + }); + }; + + tick(); + }); + + return { promise, cancel }; +} + +export function useTVThemeMusic(itemId: string | undefined) { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { settings } = useSettings(); + const soundRef = useRef(null); + const fadeRef = useRef<{ cancel: () => void } | null>(null); + + const enabled = + Platform.isTV && + !!api && + !!user?.Id && + !!itemId && + settings.tvThemeMusicEnabled; + + // Fetch theme songs + const { data: themeSongs } = useQuery({ + queryKey: ["themeSongs", itemId], + queryFn: async () => { + const result = await getLibraryApi(api!).getThemeSongs({ + itemId: itemId!, + userId: user!.Id!, + inheritFromParent: true, + }); + return result.data; + }, + enabled, + staleTime: 5 * 60 * 1000, + }); + + // Load and play audio when theme songs are available and enabled + useEffect(() => { + if (!enabled || !themeSongs?.Items?.length || !api) { + return; + } + + const themeItem = themeSongs.Items[0]; + + let mounted = true; + const sound = new Audio.Sound(); + soundRef.current = sound; + + const loadAndPlay = async () => { + try { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + staysActiveInBackground: false, + }); + + const params = new URLSearchParams({ + UserId: user!.Id!, + DeviceId: api.deviceInfo.id ?? "", + MaxStreamingBitrate: "140000000", + Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav", + TranscodingContainer: "mp4", + TranscodingProtocol: "http", + AudioCodec: "aac", + ApiKey: api.accessToken ?? "", + EnableRedirection: "true", + EnableRemoteMedia: "false", + }); + const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; + await sound.loadAsync({ uri: url }); + if (!mounted) { + await sound.unloadAsync(); + return; + } + await sound.setIsLoopingAsync(true); + await sound.setVolumeAsync(0); + await sound.playAsync(); + if (mounted) { + // Fade in + const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION); + fadeRef.current = fade; + await fade.promise; + } + } catch (e) { + console.warn("Theme music playback error:", e); + } + }; + + loadAndPlay(); + + // Cleanup: fade out then unload + return () => { + mounted = false; + // Cancel any in-progress fade + fadeRef.current?.cancel(); + fadeRef.current = null; + + const cleanupSound = async () => { + try { + const status = await sound.getStatusAsync(); + if (status.isLoaded) { + const currentVolume = status.volume ?? TARGET_VOLUME; + const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION); + await fade.promise; + await sound.stopAsync(); + await sound.unloadAsync(); + } + } catch { + // Sound may already be unloaded + try { + await sound.unloadAsync(); + } catch { + // ignore + } + } + }; + + cleanupSound(); + soundRef.current = null; + }; + }, [enabled, themeSongs, api]); +} diff --git a/package.json b/package.json index d4750406a..f231e2bf7 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "expo": "~54.0.31", "expo-application": "~7.0.8", "expo-asset": "~12.0.12", + "expo-av": "^16.0.8", "expo-background-task": "~1.0.10", "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", diff --git a/translations/en.json b/translations/en.json index bcc2c03d9..722a105c7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -128,6 +128,7 @@ "show_home_backdrop": "Dynamic Home Backdrop", "show_hero_carousel": "Hero Carousel", "show_series_poster_on_episode": "Show Series Poster on Episodes", + "theme_music": "Theme Music", "display_size": "Display Size", "display_size_small": "Small", "display_size_default": "Default", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 59aea1266..63b2ee16d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -220,6 +220,7 @@ export type Settings = { showTVHeroCarousel: boolean; tvTypographyScale: TVTypographyScale; showSeriesPosterOnEpisode: boolean; + tvThemeMusicEnabled: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -316,6 +317,7 @@ export const defaultValues: Settings = { showTVHeroCarousel: true, tvTypographyScale: TVTypographyScale.Default, showSeriesPosterOnEpisode: false, + tvThemeMusicEnabled: true, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, From 94ac458f5212c19a1253dae0d65d0224c2258593 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 22:51:35 +0100 Subject: [PATCH 138/309] refactor(tv): use shared components and proper typography in actor page --- components/persons/TVActorPage.tsx | 144 ++++++++++------------------- 1 file changed, 47 insertions(+), 97 deletions(-) diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index eacfd7b1c..f7062c611 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -19,17 +19,19 @@ import { Dimensions, Easing, FlatList, - Pressable, ScrollView, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import MoviePoster from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVItemCardText } from "@/components/tv/TVItemCardText"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -44,64 +46,6 @@ const ACTOR_IMAGE_SIZE = 250; const ITEM_GAP = 16; const SCALE_PADDING = 20; -// Focusable poster wrapper component for TV -const TVFocusablePoster: React.FC<{ - children: React.ReactNode; - onPress: () => void; - onLongPress?: () => void; - hasTVPreferredFocus?: boolean; - onFocus?: () => void; - onBlur?: () => void; -}> = ({ - children, - onPress, - onLongPress, - hasTVPreferredFocus, - onFocus, - onBlur, -}) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (value: number) => - Animated.timing(scale, { - toValue: value, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - return ( - { - setFocused(true); - animateTo(1.05); - onFocus?.(); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - onBlur?.(); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - {children} - - - ); -}; - interface TVActorPageProps { personId: string; } @@ -114,6 +58,7 @@ export const TVActorPage: React.FC = ({ personId }) => { const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; const posterSizes = useScaledTVPosterSizes(); + const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -294,29 +239,48 @@ export const TVActorPage: React.FC = ({ personId }) => { [], ); - // Render filmography item - const renderFilmographyItem = useCallback( - ( - { item: filmItem, index }: { item: BaseItemDto; index: number }, - isFirstSection: boolean, - ) => ( + // Render movie filmography item + const renderMovieItem = useCallback( + ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( handleItemPress(filmItem)} onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} - hasTVPreferredFocus={isFirstSection && index === 0} + hasTVPreferredFocus={index === 0} > - - + + ), - [handleItemPress, showItemActions], + [handleItemPress, showItemActions, posterSizes.poster], + ); + + // Render series filmography item + const renderSeriesItem = useCallback( + ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( + + handleItemPress(filmItem)} + onLongPress={() => showItemActions(filmItem)} + onFocus={() => setFocusedItem(filmItem)} + hasTVPreferredFocus={movies.length === 0 && index === 0} + > + + + + + + + + + ), + [handleItemPress, showItemActions, posterSizes.poster, movies.length], ); if (isLoadingActor) { @@ -386,28 +350,16 @@ export const TVActorPage: React.FC = ({ personId }) => { )} - {/* Gradient overlays for readability */} + {/* Gradient overlay for readability */} - @@ -471,7 +423,7 @@ export const TVActorPage: React.FC = ({ personId }) => { {/* Actor name */} = ({ personId }) => { {item.ProductionYear && ( = ({ personId }) => { {item.Overview && ( = ({ personId }) => { = ({ personId }) => { horizontal data={movies} keyExtractor={(filmItem) => filmItem.Id!} - renderItem={(props) => renderFilmographyItem(props, true)} + renderItem={renderMovieItem} showsHorizontalScrollIndicator={false} initialNumToRender={6} maxToRenderPerBatch={4} @@ -575,7 +527,7 @@ export const TVActorPage: React.FC = ({ personId }) => { = ({ personId }) => { horizontal data={series} keyExtractor={(filmItem) => filmItem.Id!} - renderItem={(props) => - renderFilmographyItem(props, movies.length === 0) - } + renderItem={renderSeriesItem} showsHorizontalScrollIndicator={false} initialNumToRender={6} maxToRenderPerBatch={4} @@ -615,7 +565,7 @@ export const TVActorPage: React.FC = ({ personId }) => { From 4bea01c96384acd2e860afe0474621e681a366b2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 07:31:34 +0100 Subject: [PATCH 139/309] fix(tv): prevent theme music from playing twice on shared transitions --- hooks/useTVThemeMusic.ts | 125 ++++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts index 753b3e649..0a9b1dcce 100644 --- a/hooks/useTVThemeMusic.ts +++ b/hooks/useTVThemeMusic.ts @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import type { Audio as AudioType } from "expo-av"; import { Audio } from "expo-av"; import { useAtom } from "jotai"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { Platform } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -63,12 +63,60 @@ function fadeVolume( return { promise, cancel }; } +// --- Module-level singleton state --- +let sharedSound: AudioType.Sound | null = null; +let currentSongId: string | null = null; +let ownerCount = 0; +let activeFade: { cancel: () => void } | null = null; +let cleanupPromise: Promise | null = null; + +/** Fade out, stop, and unload the shared sound. */ +async function teardownSharedSound(): Promise { + const sound = sharedSound; + if (!sound) return; + + activeFade?.cancel(); + activeFade = null; + + try { + const status = await sound.getStatusAsync(); + if (status.isLoaded) { + const currentVolume = status.volume ?? TARGET_VOLUME; + const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION); + activeFade = fade; + await fade.promise; + activeFade = null; + await sound.stopAsync(); + await sound.unloadAsync(); + } + } catch { + try { + await sound.unloadAsync(); + } catch { + // ignore + } + } + + if (sharedSound === sound) { + sharedSound = null; + currentSongId = null; + } +} + +/** Begin cleanup idempotently; returns the shared promise. */ +function beginCleanup(): Promise { + if (!cleanupPromise) { + cleanupPromise = teardownSharedSound().finally(() => { + cleanupPromise = null; + }); + } + return cleanupPromise; +} + export function useTVThemeMusic(itemId: string | undefined) { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { settings } = useSettings(); - const soundRef = useRef(null); - const fadeRef = useRef<{ cancel: () => void } | null>(null); const enabled = Platform.isTV && @@ -99,12 +147,30 @@ export function useTVThemeMusic(itemId: string | undefined) { } const themeItem = themeSongs.Items[0]; + const songId = themeItem.Id!; + ownerCount++; let mounted = true; - const sound = new Audio.Sound(); - soundRef.current = sound; - const loadAndPlay = async () => { + const startPlayback = async () => { + // If the same song is already playing, keep it going + if (currentSongId === songId && sharedSound) { + return; + } + + // If a different song is playing (or cleanup is in progress), tear it down first + if (sharedSound || cleanupPromise) { + activeFade?.cancel(); + activeFade = null; + await beginCleanup(); + } + + if (!mounted) return; + + const sound = new Audio.Sound(); + sharedSound = sound; + currentSongId = songId; + try { await Audio.setAudioModeAsync({ playsInSilentModeIOS: true, @@ -125,55 +191,42 @@ export function useTVThemeMusic(itemId: string | undefined) { }); const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; await sound.loadAsync({ uri: url }); - if (!mounted) { + + if (!mounted || sharedSound !== sound) { await sound.unloadAsync(); return; } + await sound.setIsLoopingAsync(true); await sound.setVolumeAsync(0); await sound.playAsync(); - if (mounted) { - // Fade in + + if (mounted && sharedSound === sound) { const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION); - fadeRef.current = fade; + activeFade = fade; await fade.promise; + activeFade = null; } } catch (e) { console.warn("Theme music playback error:", e); } }; - loadAndPlay(); + startPlayback(); - // Cleanup: fade out then unload + // Cleanup: decrement owner count, defer teardown check return () => { mounted = false; - // Cancel any in-progress fade - fadeRef.current?.cancel(); - fadeRef.current = null; + ownerCount--; - const cleanupSound = async () => { - try { - const status = await sound.getStatusAsync(); - if (status.isLoaded) { - const currentVolume = status.volume ?? TARGET_VOLUME; - const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION); - await fade.promise; - await sound.stopAsync(); - await sound.unloadAsync(); - } - } catch { - // Sound may already be unloaded - try { - await sound.unloadAsync(); - } catch { - // ignore - } + // Defer the check so React can finish processing both unmount + mount + // in the same commit. If another instance mounts (same song), ownerCount + // will be back to >0 and we skip teardown entirely. + setTimeout(() => { + if (ownerCount === 0) { + beginCleanup(); } - }; - - cleanupSound(); - soundRef.current = null; + }, 0); }; }, [enabled, themeSongs, api]); } From 01298c9b6d4c143d8d7885267655517d3b94fbba Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 07:32:13 +0100 Subject: [PATCH 140/309] chore(i18n): add no_results translation key to common section --- translations/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/translations/en.json b/translations/en.json index 722a105c7..18a6fbbd9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -530,6 +530,7 @@ } }, "common": { + "no_results": "No Results", "select": "Select", "no_trailer_available": "No trailer available", "video": "Video", @@ -596,6 +597,7 @@ "movies": "Movies", "series": "Series", "boxsets": "Box Sets", + "playlists": "Playlists", "items": "Items" }, "options": { From 80136f18003804c1a88092b18d18786b0972a76d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 07:38:56 +0100 Subject: [PATCH 141/309] feat(tv): enable video playlists library with square thumbnail grid --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 70 ++++++++++++++++++- components/common/TouchableItemRouter.tsx | 12 +++- components/library/TVLibraries.tsx | 11 ++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 9fe9733c5..2ac4c58ee 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -11,6 +11,7 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo } from "react"; @@ -70,10 +71,12 @@ import { } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; const _TV_SCALE_PADDING = 20; +const TV_PLAYLIST_SQUARE_SIZE = 180; const Page = () => { const searchParams = useLocalSearchParams() as { @@ -288,6 +291,8 @@ const Page = () => { itemType = "Video"; } else if (library.CollectionType === "musicvideos") { itemType = "MusicVideo"; + } else if (library.CollectionType === "playlists") { + itemType = "Playlist"; } const response = await getItemsApi(api).getItems({ @@ -307,6 +312,9 @@ const Page = () => { tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), includeItemTypes: itemType ? [itemType] : undefined, + ...(Platform.isTV && library.CollectionType === "playlists" + ? { mediaTypes: ["Video"] } + : {}), }); return response.data || null; @@ -403,10 +411,70 @@ const Page = () => { const renderTVItem = useCallback( (item: BaseItemDto) => { const handlePress = () => { + if (item.Type === "Playlist") { + router.push({ + pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + params: { libraryId: item.Id! }, + }); + return; + } const navTarget = getItemNavigation(item, "(libraries)"); router.push(navTarget as any); }; + // Special rendering for Playlist items (square thumbnails) + if (item.Type === "Playlist") { + const playlistImageUrl = getPrimaryImageUrl({ + api, + item, + width: TV_PLAYLIST_SQUARE_SIZE * 2, + }); + + return ( + + showItemActions(item)} + > + + + + + + + {item.Name} + + + + ); + } + return ( { ); }, - [router, showItemActions], + [router, showItemActions, api, typography], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 2c85e0947..cc40d2dc7 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; -import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import { + Platform, + TouchableOpacity, + type TouchableOpacityProps, +} from "react-native"; import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; @@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { } if (item.Type === "Playlist") { + if (Platform.isTV) { + return { + pathname: "/[libraryId]" as const, + params: { libraryId: item.Id! }, + }; + } return { pathname: "/music/playlist/[playlistId]" as const, params: { playlistId: item.Id! }, diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index 4c985dc00..8bcff9da3 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -103,6 +103,8 @@ const TVLibraryRow: React.FC<{ return t("library.item_types.series"); if (library.CollectionType === "boxsets") return t("library.item_types.boxsets"); + if (library.CollectionType === "playlists") + return t("library.item_types.playlists"); if (library.CollectionType === "music") return t("library.item_types.items"); return t("library.item_types.items"); @@ -258,8 +260,7 @@ export const TVLibraries: React.FC = () => { userViews ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) .filter((l) => l.CollectionType !== "books") - .filter((l) => l.CollectionType !== "music") - .filter((l) => l.CollectionType !== "playlists") || [], + .filter((l) => l.CollectionType !== "music") || [], [userViews, settings?.hiddenLibraries], ); @@ -273,6 +274,10 @@ export const TVLibraries: React.FC = () => { if (library.CollectionType === "movies") itemType = "Movie"; else if (library.CollectionType === "tvshows") itemType = "Series"; else if (library.CollectionType === "boxsets") itemType = "BoxSet"; + else if (library.CollectionType === "playlists") + itemType = "Playlist"; + + const isPlaylistsLib = library.CollectionType === "playlists"; // Fetch count const countResponse = await getItemsApi(api!).getItems({ @@ -281,6 +286,7 @@ export const TVLibraries: React.FC = () => { recursive: true, limit: 0, includeItemTypes: itemType ? [itemType as any] : undefined, + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), }); // Fetch preview items with backdrops @@ -292,6 +298,7 @@ export const TVLibraries: React.FC = () => { sortBy: ["Random"], includeItemTypes: itemType ? [itemType as any] : undefined, imageTypes: ["Backdrop"], + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), }); return { From 2c0a9b6cd9fe86a3e7303ea6ee586c942674aa7d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 12:12:20 +0100 Subject: [PATCH 142/309] feat(tv): migrate login to white design with navigation modals --- .claude/agents/tv-validator.md | 103 ++++++ app/_layout.tsx | 16 + app/tv-account-select-modal.tsx | 159 +++++++++ app/tv-server-action-modal.tsx | 250 +++++++++++++ components/Button.tsx | 2 +- components/login/TVAccountCard.tsx | 5 +- components/login/TVInput.tsx | 15 +- components/login/TVLogin.tsx | 117 +++--- components/login/TVPINEntryModal.tsx | 4 +- components/login/TVPasswordEntryModal.tsx | 16 +- components/login/TVPreviousServersList.tsx | 395 ++++----------------- components/login/TVSaveAccountModal.tsx | 12 +- components/login/TVSaveAccountToggle.tsx | 7 +- components/login/TVServerCard.tsx | 7 +- hooks/useTVAccountSelectModal.ts | 34 ++ hooks/useTVServerActionModal.ts | 29 ++ utils/atoms/tvAccountSelectModal.ts | 14 + utils/atoms/tvServerActionModal.ts | 10 + 18 files changed, 757 insertions(+), 438 deletions(-) create mode 100644 .claude/agents/tv-validator.md create mode 100644 app/tv-account-select-modal.tsx create mode 100644 app/tv-server-action-modal.tsx create mode 100644 hooks/useTVAccountSelectModal.ts create mode 100644 hooks/useTVServerActionModal.ts create mode 100644 utils/atoms/tvAccountSelectModal.ts create mode 100644 utils/atoms/tvServerActionModal.ts diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md new file mode 100644 index 000000000..a38dd751a --- /dev/null +++ b/.claude/agents/tv-validator.md @@ -0,0 +1,103 @@ +--- +name: tv-validator +description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements. +tools: Read, Glob, Grep +model: haiku +color: blue +--- + +You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations. + +## Critical Rules to Check + +### 1. No .tv.tsx File Suffix +The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it. + +**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution +**Correct**: Use `Platform.isTV` conditional rendering in the main file: +```typescript +if (Platform.isTV) { + return ; +} +return ; +``` + +### 2. No FlashList on TV +FlashList has focus issues on TV. Use FlatList instead. + +**Violation**: ` +) : ( + +)} +``` + +### 3. Modal Pattern +Never use overlay/absolute-positioned modals on TV. They break back button handling. + +**Violation**: `position: "absolute"` or `Modal` component for TV overlays +**Correct**: Use navigation-based pattern: +- Create Jotai atom for state +- Hook that sets atom and calls `router.push()` +- Page in `app/(auth)/` that reads atom +- `Stack.Screen` with `presentation: "transparentModal"` + +### 4. Typography +All TV text must use `TVTypography` component. + +**Violation**: Raw `` in TV components +**Correct**: `...` + +### 5. No Purple Accent Colors +TV uses white for focus states, not purple. + +**Violation**: Purple/violet colors in TV focused states +**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds + +### 6. Focus Handling +- Only ONE element should have `hasTVPreferredFocus={true}` +- Focusable items need `disabled={isModalOpen}` when overlays are visible +- Use `onFocus`/`onBlur` with scale animations +- Add padding for scale animations (focus scale clips without it) + +### 7. List Configuration +TV lists need: +- `removeClippedSubviews={false}` +- `overflow: "visible"` on containers +- Sufficient padding for focus scale animations + +### 8. Horizontal Padding +Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20). + +### 9. Focus Guide Navigation +For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop. +Use `useState` for refs (not `useRef`) to trigger re-renders. + +## Review Process + +1. Read the file(s) to review +2. Check each rule above +3. Report violations with: + - Line number + - What's wrong + - How to fix it +4. If no violations, confirm the code follows TV patterns + +## Output Format + +``` +## TV Validation Results + +### ✓ Passes +- [List of rules that pass] + +### ✗ Violations +- **[Rule Name]** (line X): [Description] + Fix: [How to correct it] + +### Recommendations +- [Optional suggestions for improvement] +``` diff --git a/app/_layout.tsx b/app/_layout.tsx index ad2ca9917..9b8241331 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -466,6 +466,22 @@ function Layout() { animation: "fade", }} /> + + { + overlayOpacity.setValue(0); + contentScale.setValue(0.9); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(contentScale, { + toValue: 1, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvAccountSelectModalAtom, null); + }; + }, [overlayOpacity, contentScale]); + + const handleClose = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + + {t("server.select_account")} + + + {modalState.server.name || modalState.server.address} + + + {isReady && ( + <> + + {modalState.server.accounts?.map((account, index) => ( + { + modalState.onAccountSelect(account); + router.back(); + }} + onLongPress={() => { + modalState.onDeleteAccount(account); + }} + hasTVPreferredFocus={index === 0} + /> + ))} + + + + + + + + )} + + + + + ); +} diff --git a/app/tv-server-action-modal.tsx b/app/tv-server-action-modal.tsx new file mode 100644 index 000000000..24a9637cd --- /dev/null +++ b/app/tv-server-action-modal.tsx @@ -0,0 +1,250 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + Pressable, + ScrollView, + TVFocusGuideView, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal"; +import { store } from "@/utils/store"; + +// Action card component +const TVServerActionCard: React.FC<{ + label: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: "default" | "destructive"; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const typography = useScaledTVTypography(); + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isDestructive = variant === "destructive"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + + ); +}; + +export default function TVServerActionModalPage() { + const typography = useScaledTVTypography(); + const router = useRouter(); + const modalState = useAtomValue(tvServerActionModalAtom); + const { t } = useTranslation(); + + const [isReady, setIsReady] = useState(false); + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvServerActionModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + const handleLogin = () => { + modalState?.onLogin(); + router.back(); + }; + + const handleDelete = () => { + modalState?.onDelete(); + router.back(); + }; + + const handleClose = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + {/* Title */} + + {modalState.server.name || modalState.server.address} + + + {/* Horizontal options */} + {isReady && ( + + + + + + )} + + + + + ); +} diff --git a/components/Button.tsx b/components/Button.tsx index 03e9296d7..3b5d6351a 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -132,7 +132,7 @@ export const Button: React.FC> = ({ = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -143,7 +142,7 @@ export const TVAccountCard: React.FC = ({ {/* Security Icon */} - + diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx index 40c2b8d3b..2e9435f19 100644 --- a/components/login/TVInput.tsx +++ b/components/login/TVInput.tsx @@ -58,20 +58,25 @@ export const TVInput: React.FC = ({ { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); + const { showServerActionModal } = useTVServerActionModal(); const { setServer, login, @@ -152,20 +146,13 @@ export const TVLogin: React.FC = () => { const [selectedAccount, setSelectedAccount] = useState(null); - // Server action sheet state - const [showServerActionSheet, setShowServerActionSheet] = useState(false); - const [actionSheetServer, setActionSheetServer] = - useState(null); + // Server login trigger state const [loginTriggerServer, setLoginTriggerServer] = useState(null); - const [actionSheetKey, setActionSheetKey] = useState(0); // Track if any modal is open to disable background focus const isAnyModalOpen = - showSaveModal || - pinModalVisible || - passwordModalVisible || - showServerActionSheet; + showSaveModal || pinModalVisible || passwordModalVisible; // Auto login from URL params useEffect(() => { @@ -319,48 +306,38 @@ export const TVLogin: React.FC = () => { } }; - // Server action sheet handlers + // Server action sheet handler const handleServerAction = (server: SavedServer) => { - setActionSheetServer(server); - setActionSheetKey((k) => k + 1); // Force remount to reset focus - setShowServerActionSheet(true); - }; - - const handleServerActionLogin = () => { - setShowServerActionSheet(false); - if (actionSheetServer) { - // Trigger the login flow in TVPreviousServersList - setLoginTriggerServer(actionSheetServer); - // Reset the trigger after a tick to allow re-triggering the same server - setTimeout(() => setLoginTriggerServer(null), 0); - } - }; - - const handleServerActionDelete = () => { - if (!actionSheetServer) return; - - Alert.alert( - t("server.remove_server"), - t("server.remove_server_description", { - server: actionSheetServer.name || actionSheetServer.address, - }), - [ - { - text: t("common.cancel"), - style: "cancel", - onPress: () => setShowServerActionSheet(false), - }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - await removeServerFromList(actionSheetServer.address); - setShowServerActionSheet(false); - setActionSheetServer(null); - }, - }, - ], - ); + showServerActionModal({ + server, + onLogin: () => { + // Trigger the login flow in TVPreviousServersList + setLoginTriggerServer(server); + // Reset the trigger after a tick to allow re-triggering the same server + setTimeout(() => setLoginTriggerServer(null), 0); + }, + onDelete: () => { + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: server.name || server.address, + }), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + await removeServerFromList(server.address); + }, + }, + ], + ); + }, + }); }; const checkUrl = useCallback(async (url: string) => { @@ -493,7 +470,7 @@ export const TVLogin: React.FC = () => { {serverName ? ( <> {`${t("login.login_to_title")} `} - {serverName} + {serverName} ) : ( t("login.login_title") @@ -558,6 +535,7 @@ export const TVLogin: React.FC = () => { onPress={handleLogin} loading={loading} disabled={!credentials.username.trim() || loading} + color='white' > {t("login.login_button")} @@ -595,7 +573,7 @@ export const TVLogin: React.FC = () => { {/* Logo */} @@ -645,6 +623,7 @@ export const TVLogin: React.FC = () => { onPress={() => handleConnect(serverURL)} loading={loadingServerCheck} disabled={loadingServerCheck || !serverURL.trim()} + color='white' > {t("server.connect_button")} @@ -706,16 +685,6 @@ export const TVLogin: React.FC = () => { onSubmit={handlePasswordSubmit} username={selectedAccount?.username || ""} /> - - {/* Server Action Sheet */} - setShowServerActionSheet(false)} - /> ); }; diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx index 25d9ce741..821bf689d 100644 --- a/components/login/TVPINEntryModal.tsx +++ b/components/login/TVPINEntryModal.tsx @@ -49,7 +49,7 @@ const TVForgotPINButton: React.FC<{ paddingVertical: 10, borderRadius: 8, backgroundColor: focused - ? "rgba(168, 85, 247, 0.2)" + ? "rgba(255, 255, 255, 0.15)" : "transparent", }, ]} @@ -57,7 +57,7 @@ const TVForgotPINButton: React.FC<{ diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 1473cf861..3e5574a63 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -47,10 +47,10 @@ const TVSubmitButton: React.FC<{ animatedStyle, { backgroundColor: focused - ? "#a855f7" + ? "#fff" : isDisabled ? "#4a4a4a" - : "#7c3aed", + : "rgba(255,255,255,0.15)", paddingHorizontal: 24, paddingVertical: 14, borderRadius: 10, @@ -64,14 +64,18 @@ const TVSubmitButton: React.FC<{ ]} > {loading ? ( - + ) : ( <> - + @@ -119,7 +123,7 @@ const TVPasswordInput: React.FC<{ backgroundColor: "#1F2937", borderRadius: 12, borderWidth: 2, - borderColor: focused ? "#6366F1" : "#374151", + borderColor: focused ? "#fff" : "#374151", paddingHorizontal: 16, paddingVertical: 14, }, diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index 1903709ea..cc9ad1ff2 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -1,210 +1,20 @@ import { Ionicons } from "@expo/vector-icons"; -import { BlurView } from "expo-blur"; import type React from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Alert, - Animated, - Easing, - Modal, - Pressable, - ScrollView, - View, -} from "react-native"; +import { Alert, View } from "react-native"; import { useMMKVString } from "react-native-mmkv"; -import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVAccountSelectModal } from "@/hooks/useTVAccountSelectModal"; import { deleteAccountCredential, getPreviousServers, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; -import { TVAccountCard } from "./TVAccountCard"; import { TVServerCard } from "./TVServerCard"; -// Action card for server action sheet (Apple TV style) -const TVServerActionCard: React.FC<{ - label: string; - icon: keyof typeof Ionicons.glyphMap; - variant?: "default" | "destructive"; - hasTVPreferredFocus?: boolean; - onPress: () => void; -}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - const isDestructive = variant === "destructive"; - - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - - {label} - - - - ); -}; - -// Server action sheet component (bottom sheet with horizontal scrolling) -const TVServerActionSheet: React.FC<{ - visible: boolean; - server: SavedServer | null; - onLogin: () => void; - onDelete: () => void; - onClose: () => void; -}> = ({ visible, server, onLogin, onDelete, onClose }) => { - const { t } = useTranslation(); - - if (!server) return null; - - return ( - - - - - {/* Title */} - - {server.name || server.address} - - - {/* Horizontal options */} - - - - - - - - - - ); -}; - interface TVPreviousServersListProps { onServerSelect: (server: SavedServer) => void; onQuickLogin?: (serverUrl: string, userId: string) => Promise; @@ -227,9 +37,6 @@ interface TVPreviousServersListProps { disabled?: boolean; } -// Export the action sheet for use in parent components -export { TVServerActionSheet }; - export const TVPreviousServersList: React.FC = ({ onServerSelect, onQuickLogin, @@ -241,37 +48,16 @@ export const TVPreviousServersList: React.FC = ({ disabled = false, }) => { const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const { showAccountSelectModal } = useTVAccountSelectModal(); const [_previousServers, setPreviousServers] = useMMKVString("previousServers"); const [loadingServer, setLoadingServer] = useState(null); - const [selectedServer, setSelectedServer] = useState( - null, - ); - const [showAccountsModal, setShowAccountsModal] = useState(false); const previousServers = useMemo(() => { return JSON.parse(_previousServers || "[]") as SavedServer[]; }, [_previousServers]); - // When parent triggers login via loginServerOverride, execute the login flow - useEffect(() => { - if (loginServerOverride) { - const accountCount = loginServerOverride.accounts?.length || 0; - - if (accountCount === 0) { - onServerSelect(loginServerOverride); - } else if (accountCount === 1) { - handleAccountLogin( - loginServerOverride, - loginServerOverride.accounts[0], - ); - } else { - setSelectedServer(loginServerOverride); - setShowAccountsModal(true); - } - } - }, [loginServerOverride]); - const refreshServers = () => { const servers = getPreviousServers(); setPreviousServers(JSON.stringify(servers)); @@ -281,8 +67,6 @@ export const TVPreviousServersList: React.FC = ({ server: SavedServer, account: SavedServerAccount, ) => { - setShowAccountsModal(false); - switch (account.securityType) { case "none": if (onQuickLogin) { @@ -315,6 +99,58 @@ export const TVPreviousServersList: React.FC = ({ } }; + const handleDeleteAccount = async ( + server: SavedServer, + account: SavedServerAccount, + ) => { + Alert.alert( + t("server.remove_saved_login"), + t("server.remove_account_description", { username: account.username }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.remove"), + style: "destructive", + onPress: async () => { + await deleteAccountCredential(server.address, account.userId); + refreshServers(); + }, + }, + ], + ); + }; + + const showAccountSelection = (server: SavedServer) => { + showAccountSelectModal({ + server, + onAccountSelect: (account) => handleAccountLogin(server, account), + onAddAccount: () => { + if (onAddAccount) { + onAddAccount(server); + } + }, + onDeleteAccount: (account) => handleDeleteAccount(server, account), + }); + }; + + // When parent triggers login via loginServerOverride, execute the login flow + useEffect(() => { + if (loginServerOverride) { + const accountCount = loginServerOverride.accounts?.length || 0; + + if (accountCount === 0) { + onServerSelect(loginServerOverride); + } else if (accountCount === 1) { + handleAccountLogin( + loginServerOverride, + loginServerOverride.accounts[0], + ); + } else { + showAccountSelection(loginServerOverride); + } + } + }, [loginServerOverride]); + const handleServerPress = (server: SavedServer) => { if (loadingServer) return; @@ -331,8 +167,7 @@ export const TVPreviousServersList: React.FC = ({ } else if (accountCount === 1) { handleAccountLogin(server, server.accounts[0]); } else { - setSelectedServer(server); - setShowAccountsModal(true); + showAccountSelection(server); } }; @@ -369,39 +204,13 @@ export const TVPreviousServersList: React.FC = ({ } }; - const handleDeleteAccount = async (account: SavedServerAccount) => { - if (!selectedServer) return; - - Alert.alert( - t("server.remove_saved_login"), - t("server.remove_account_description", { username: account.username }), - [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.remove"), - style: "destructive", - onPress: async () => { - await deleteAccountCredential( - selectedServer.address, - account.userId, - ); - refreshServers(); - if (selectedServer.accounts.length <= 1) { - setShowAccountsModal(false); - } - }, - }, - ], - ); - }; - if (!previousServers.length) return null; return ( = ({ /> ))} - - {/* TV Account Selection Modal */} - setShowAccountsModal(false)} - > - - - - {t("server.select_account")} - - - {selectedServer?.name || selectedServer?.address} - - - - {selectedServer?.accounts.map((account, index) => ( - - selectedServer && - handleAccountLogin(selectedServer, account) - } - onLongPress={() => handleDeleteAccount(account)} - hasTVPreferredFocus={index === 0} - /> - ))} - - - - - - - - - ); }; diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx index a1c7d55c0..39f4fb8cb 100644 --- a/components/login/TVSaveAccountModal.tsx +++ b/components/login/TVSaveAccountModal.tsx @@ -75,10 +75,10 @@ const TVSaveButton: React.FC<{ animatedStyle, { backgroundColor: focused - ? "#a855f7" + ? "#fff" : disabled ? "#4a4a4a" - : "#7c3aed", + : "rgba(255,255,255,0.15)", paddingHorizontal: 24, paddingVertical: 14, borderRadius: 10, @@ -89,11 +89,15 @@ const TVSaveButton: React.FC<{ }, ]} > - + diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx index 85ccc3f1d..fc8432567 100644 --- a/components/login/TVSaveAccountToggle.tsx +++ b/components/login/TVSaveAccountToggle.tsx @@ -1,7 +1,6 @@ import React, { useRef, useState } from "react"; import { Animated, Easing, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { Colors } from "@/constants/Colors"; interface TVSaveAccountToggleProps { value: boolean; @@ -62,7 +61,7 @@ export const TVSaveAccountToggle: React.FC = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -97,7 +96,7 @@ export const TVSaveAccountToggle: React.FC = ({ width: 60, height: 34, borderRadius: 17, - backgroundColor: value ? Colors.primary : "#3f3f46", + backgroundColor: value ? "#fff" : "#3f3f46", justifyContent: "center", paddingHorizontal: 3, }} @@ -107,7 +106,7 @@ export const TVSaveAccountToggle: React.FC = ({ width: 28, height: 28, borderRadius: 14, - backgroundColor: "white", + backgroundColor: value ? "#000" : "#fff", alignSelf: value ? "flex-end" : "flex-start", }} /> diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx index b75b91ace..4325cdd62 100644 --- a/components/login/TVServerCard.tsx +++ b/components/login/TVServerCard.tsx @@ -8,7 +8,6 @@ import { View, } from "react-native"; import { Text } from "@/components/common/Text"; -import { Colors } from "@/constants/Colors"; interface TVServerCardProps { title: string; @@ -75,7 +74,7 @@ export const TVServerCard: React.FC = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -123,13 +122,13 @@ export const TVServerCard: React.FC = ({ {isLoading ? ( - + ) : securityIcon ? ( void; + onAddAccount: () => void; + onDeleteAccount: (account: SavedServerAccount) => void; +} + +export const useTVAccountSelectModal = () => { + const router = useRouter(); + + const showAccountSelectModal = useCallback( + (params: ShowAccountSelectModalParams) => { + store.set(tvAccountSelectModalAtom, { + server: params.server, + onAccountSelect: params.onAccountSelect, + onAddAccount: params.onAddAccount, + onDeleteAccount: params.onDeleteAccount, + }); + router.push("/tv-account-select-modal"); + }, + [router], + ); + + return { showAccountSelectModal }; +}; diff --git a/hooks/useTVServerActionModal.ts b/hooks/useTVServerActionModal.ts new file mode 100644 index 000000000..f0da43f13 --- /dev/null +++ b/hooks/useTVServerActionModal.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal"; +import type { SavedServer } from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowServerActionModalParams { + server: SavedServer; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVServerActionModal = () => { + const router = useRouter(); + + const showServerActionModal = useCallback( + (params: ShowServerActionModalParams) => { + store.set(tvServerActionModalAtom, { + server: params.server, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-server-action-modal"); + }, + [router], + ); + + return { showServerActionModal }; +}; diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts new file mode 100644 index 000000000..3cafa61e2 --- /dev/null +++ b/utils/atoms/tvAccountSelectModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountSelectModalState = { + server: SavedServer; + onAccountSelect: (account: SavedServerAccount) => void; + onAddAccount: () => void; + onDeleteAccount: (account: SavedServerAccount) => void; +} | null; + +export const tvAccountSelectModalAtom = atom(null); diff --git a/utils/atoms/tvServerActionModal.ts b/utils/atoms/tvServerActionModal.ts new file mode 100644 index 000000000..38d99e839 --- /dev/null +++ b/utils/atoms/tvServerActionModal.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; +import type { SavedServer } from "@/utils/secureCredentials"; + +export type TVServerActionModalState = { + server: SavedServer; + onLogin: () => void; + onDelete: () => void; +} | null; + +export const tvServerActionModalAtom = atom(null); From bf3a37c61c82197e9b575a6427ba6e8cb7ee1902 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 18:05:53 +0100 Subject: [PATCH 143/309] feat(player): change technical info button icon to code-slash --- .../video-player/controls/Controls.tv.tsx | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 18369ab4e..bf4d2eed9 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -375,6 +375,15 @@ export const Controls: FC = ({ isSeeking, }); + // Countdown logic - needs to be early so toggleControls can reference it + const shouldShowCountdown = useMemo(() => { + if (!nextItem) return false; + if (item?.Type !== "Episode") return false; + return remainingTime > 0 && remainingTime <= 10000; + }, [nextItem, item, remainingTime]); + + const isCountdownActive = shouldShowCountdown; + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; @@ -760,7 +769,11 @@ export const Controls: FC = ({ calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); - seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); controlsInteractionRef.current(); }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); @@ -792,7 +805,11 @@ export const Controls: FC = ({ calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); - seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); controlsInteractionRef.current(); }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); @@ -826,10 +843,12 @@ export const Controls: FC = ({ }, []); // Callback for up/down D-pad - show controls with play button focused + // Skip if countdown is active (card has focus, don't show controls) const handleVerticalDpad = useCallback(() => { + if (isCountdownActive) return; setFocusPlayButton(true); setShowControls(true); - }, [setShowControls]); + }, [setShowControls, isCountdownActive]); const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, @@ -934,12 +953,6 @@ export const Controls: FC = ({ goToNextItemRef.current = goToNextItem; - const shouldShowCountdown = useMemo(() => { - if (!nextItem) return false; - if (item?.Type !== "Episode") return false; - return remainingTime > 0 && remainingTime <= 10000; - }, [nextItem, item, remainingTime]); - const handleAutoPlayFinish = useCallback(() => { goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); @@ -971,6 +984,9 @@ export const Controls: FC = ({ show={shouldShowCountdown} isPlaying={isPlaying} onFinish={handleAutoPlayFinish} + onPlayNext={handleNextItemButton} + hasFocus={isCountdownActive} + controlsVisible={showControls} /> )} @@ -1101,21 +1117,25 @@ export const Controls: FC = ({ @@ -1125,8 +1145,10 @@ export const Controls: FC = ({ )} @@ -1134,17 +1156,21 @@ export const Controls: FC = ({ {getTechnicalInfo && ( )} @@ -1187,7 +1213,12 @@ export const Controls: FC = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - hasTVPreferredFocus={lastOpenedModal === null && !focusPlayButton} + disabled={isCountdownActive} + hasTVPreferredFocus={ + !isCountdownActive && + lastOpenedModal === null && + !focusPlayButton + } /> From 53902aebabd5e2284e59cfdce953e99a02a47fbb Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 18:17:43 +0100 Subject: [PATCH 144/309] feat(tv): change playback options layout to horizontal row --- components/ItemContent.tv.tsx | 53 +++++--------------------------- components/tv/TVOptionButton.tsx | 9 +++++- 2 files changed, 15 insertions(+), 47 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index d2f8ff1e0..ae806f316 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -184,10 +184,6 @@ export const ItemContentTV: React.FC = React.memo( null, ); - // State for last option button ref (used for upward focus guide from cast) - const [_lastOptionButtonRef, setLastOptionButtonRef] = - useState(null); - // Get available audio tracks const audioTracks = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( @@ -442,25 +438,6 @@ export const ItemContentTV: React.FC = React.memo( return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; }, [api, item]); - // Determine which option button is the last one (for focus guide targeting) - const lastOptionButton = useMemo(() => { - const hasSubtitleOption = - subtitleStreams.length > 0 || - selectedOptions?.subtitleIndex !== undefined; - const hasAudioOption = audioTracks.length > 0; - const hasMediaSourceOption = mediaSources.length > 1; - - if (hasSubtitleOption) return "subtitle"; - if (hasAudioOption) return "audio"; - if (hasMediaSourceOption) return "mediaSource"; - return "quality"; - }, [ - subtitleStreams.length, - selectedOptions?.subtitleIndex, - audioTracks.length, - mediaSources.length, - ]); - // Navigation handlers const handleActorPress = useCallback( (personId: string) => { @@ -658,21 +635,17 @@ export const ItemContentTV: React.FC = React.memo( {/* Playback options */} {/* Quality selector */} showOptions({ title: t("item_card.quality"), @@ -685,13 +658,9 @@ export const ItemContentTV: React.FC = React.memo( {/* Media source selector (only if multiple sources) */} {mediaSources.length > 1 && ( showOptions({ title: t("item_card.video"), @@ -705,13 +674,9 @@ export const ItemContentTV: React.FC = React.memo( {/* Audio selector */} {audioTracks.length > 0 && ( showOptions({ title: t("item_card.audio"), @@ -726,13 +691,9 @@ export const ItemContentTV: React.FC = React.memo( {(subtitleStreams.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( showSubtitleModal({ item, diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx index 413595618..562bd6341 100644 --- a/components/tv/TVOptionButton.tsx +++ b/components/tv/TVOptionButton.tsx @@ -10,10 +10,11 @@ export interface TVOptionButtonProps { value: string; onPress: () => void; hasTVPreferredFocus?: boolean; + maxWidth?: number; } export const TVOptionButton = React.forwardRef( - ({ label, value, onPress, hasTVPreferredFocus }, ref) => { + ({ label, value, onPress, hasTVPreferredFocus, maxWidth }, ref) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); @@ -47,12 +48,14 @@ export const TVOptionButton = React.forwardRef( flexDirection: "row", alignItems: "center", gap: 8, + maxWidth, }} > {label} @@ -62,6 +65,7 @@ export const TVOptionButton = React.forwardRef( fontSize: typography.callout, color: "#000", fontWeight: "500", + flexShrink: 1, }} numberOfLines={1} > @@ -75,6 +79,7 @@ export const TVOptionButton = React.forwardRef( style={{ borderRadius: 8, overflow: "hidden", + maxWidth, }} > ( style={{ fontSize: typography.callout, color: "#bbb", + flexShrink: 0, }} > {label} @@ -100,6 +106,7 @@ export const TVOptionButton = React.forwardRef( fontSize: typography.callout, color: "#E5E7EB", fontWeight: "500", + flexShrink: 1, }} numberOfLines={1} > From 3827350ffd98adca0c9b3b52c267c00c3f10f68a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 18:22:28 +0100 Subject: [PATCH 145/309] feat(tv): add focus management to next episode countdown card --- components/tv/TVNextEpisodeCountdown.tsx | 160 +++++++++++++----- .../video-player/controls/Controls.tv.tsx | 36 ++-- 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index ef1ea6cc3..6462b6599 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -3,7 +3,13 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; import { type FC, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Image, StyleSheet, View } from "react-native"; +import { + Image, + Pressable, + Animated as RNAnimated, + StyleSheet, + View, +} from "react-native"; import Animated, { cancelAnimation, Easing, @@ -15,6 +21,7 @@ import Animated, { import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVNextEpisodeCountdownProps { nextItem: BaseItemDto; @@ -22,19 +29,37 @@ export interface TVNextEpisodeCountdownProps { show: boolean; isPlaying: boolean; onFinish: () => void; + /** Called when user presses the card to skip to next episode */ + onPlayNext?: () => void; + /** Whether this card should capture focus when visible */ + hasFocus?: boolean; + /** Whether controls are visible - affects card position */ + controlsVisible?: boolean; } +// Position constants +const BOTTOM_WITH_CONTROLS = 300; +const BOTTOM_WITHOUT_CONTROLS = 120; + export const TVNextEpisodeCountdown: FC = ({ nextItem, api, show, isPlaying, onFinish, + onPlayNext, + hasFocus = false, + controlsVisible = false, }) => { const typography = useScaledTVTypography(); const { t } = useTranslation(); const progress = useSharedValue(0); const onFinishRef = useRef(onFinish); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); onFinishRef.current = onFinish; @@ -45,25 +70,58 @@ export const TVNextEpisodeCountdown: FC = ({ quality: 80, }); + // Animated position based on controls visibility + const bottomPosition = useSharedValue( + controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, + ); + useEffect(() => { - if (show && isPlaying) { - progress.value = 0; - progress.value = withTiming( - 1, - { - duration: 8000, - easing: Easing.linear, - }, - (finished) => { - if (finished && onFinishRef.current) { - runOnJS(onFinishRef.current)(); - } - }, - ); - } else { + const target = controlsVisible + ? BOTTOM_WITH_CONTROLS + : BOTTOM_WITHOUT_CONTROLS; + bottomPosition.value = withTiming(target, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [controlsVisible, bottomPosition]); + + const containerAnimatedStyle = useAnimatedStyle(() => ({ + bottom: bottomPosition.value, + })); + + // Progress animation - pause/resume without resetting + const prevShowRef = useRef(false); + + useEffect(() => { + const justStartedShowing = show && !prevShowRef.current; + prevShowRef.current = show; + + if (!show) { cancelAnimation(progress); progress.value = 0; + return; } + + if (justStartedShowing) { + progress.value = 0; + } + + if (!isPlaying) { + cancelAnimation(progress); + return; + } + + // Resume from current position + const remainingDuration = (1 - progress.value) * 8000; + progress.value = withTiming( + 1, + { duration: remainingDuration, easing: Easing.linear }, + (finished) => { + if (finished) { + runOnJS(onFinishRef.current)(); + } + }, + ); }, [show, isPlaying, progress]); const progressStyle = useAnimatedStyle(() => ({ @@ -75,36 +133,49 @@ export const TVNextEpisodeCountdown: FC = ({ if (!show) return null; return ( - - - - {imageUrl && ( - - )} + + + + + + {imageUrl && ( + + )} - - {t("player.next_episode")} + + {t("player.next_episode")} - - {nextItem.SeriesName} - + + {nextItem.SeriesName} + - - S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} - {nextItem.Name} - + + S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} + {nextItem.Name} + - - + + + + - - - - + + + + ); }; @@ -112,10 +183,15 @@ const createStyles = (typography: ReturnType) => StyleSheet.create({ container: { position: "absolute", - bottom: 180, right: 80, zIndex: 100, }, + focusedCard: { + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.6, + shadowRadius: 16, + }, blur: { borderRadius: 16, overflow: "hidden", diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index bf4d2eed9..42b584a0f 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -376,13 +376,26 @@ export const Controls: FC = ({ }); // Countdown logic - needs to be early so toggleControls can reference it - const shouldShowCountdown = useMemo(() => { + const isCountdownActive = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); - const isCountdownActive = shouldShowCountdown; + // Brief delay to ignore focus events when countdown first appears + const countdownJustActivatedRef = useRef(false); + + useEffect(() => { + if (!isCountdownActive) { + countdownJustActivatedRef.current = false; + return; + } + countdownJustActivatedRef.current = true; + const timeout = setTimeout(() => { + countdownJustActivatedRef.current = false; + }, 200); + return () => clearTimeout(timeout); + }, [isCountdownActive]); // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; @@ -401,6 +414,8 @@ export const Controls: FC = ({ }; const toggleControls = useCallback(() => { + // Skip if countdown just became active (ignore initial focus event) + if (countdownJustActivatedRef.current) return; setShowControls(!showControls); }, [showControls, setShowControls]); @@ -843,12 +858,12 @@ export const Controls: FC = ({ }, []); // Callback for up/down D-pad - show controls with play button focused - // Skip if countdown is active (card has focus, don't show controls) const handleVerticalDpad = useCallback(() => { - if (isCountdownActive) return; + // Skip if countdown just became active (ignore initial focus event) + if (countdownJustActivatedRef.current) return; setFocusPlayButton(true); setShowControls(true); - }, [setShowControls, isCountdownActive]); + }, [setShowControls]); const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, @@ -981,7 +996,7 @@ export const Controls: FC = ({ = ({ = ({ @@ -1145,7 +1159,6 @@ export const Controls: FC = ({ = ({ = ({ = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - disabled={isCountdownActive} hasTVPreferredFocus={ !isCountdownActive && lastOpenedModal === null && From 8ecb7c205b85f93d8ddc79c5102b462445a46fde Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 21:53:45 +0100 Subject: [PATCH 146/309] feat(tv): add smart back button handler to prevent unwanted app exit --- app/(auth)/(tabs)/_layout.tsx | 4 + app/_layout.tsx | 6 ++ hooks/useTVBackHandler.ts | 175 ++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 hooks/useTVBackHandler.ts diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index abaaa82ef..1ed67edae 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { Colors } from "@/constants/Colors"; +import { useTVBackHandler } from "@/hooks/useTVBackHandler"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -36,6 +37,9 @@ export default function TabLayout() { const { settings } = useSettings(); const { t } = useTranslation(); + // Handle TV back button - prevent app exit when at root + useTVBackHandler(); + return ( ); }, - [router, showItemActions], + [router, showItemActions, posterSizes.poster], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 2ac4c58ee..ccf38d3ea 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -34,13 +34,8 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { - TVFilterButton, - TVFocusablePoster, - TVItemCardText, -} from "@/components/tv"; +import { TVFilterButton, TVFocusablePoster } from "@/components/tv"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -476,26 +471,14 @@ const Page = () => { } return ( - - showItemActions(item)} - > - {item.Type === "Movie" && } - {(item.Type === "Series" || item.Type === "Episode") && ( - - )} - {item.Type !== "Movie" && - item.Type !== "Series" && - item.Type !== "Episode" && } - - - + item={item} + orientation='vertical' + onPress={handlePress} + onLongPress={() => showItemActions(item)} + width={posterSizes.poster} + /> ); }, [router, showItemActions, api, typography], diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 07b03b17d..c649bdf62 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -24,9 +24,7 @@ import { } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -46,31 +44,6 @@ import { userAtom } from "@/providers/JellyfinProvider"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; -type Typography = ReturnType; - -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => ( - - - {item.Name} - - - {item.ProductionYear} - - -); - export default function WatchlistDetailScreen() { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -205,27 +178,18 @@ export default function WatchlistDetailScreen() { }; return ( - - showItemActions(item)} - hasTVPreferredFocus={index === 0} - > - {item.Type === "Movie" && } - {(item.Type === "Series" || item.Type === "Episode") && ( - - )} - - - + item={item} + orientation='vertical' + onPress={handlePress} + onLongPress={() => showItemActions(item)} + hasTVPreferredFocus={index === 0} + width={posterSizes.poster} + /> ); }, - [router, showItemActions, typography], + [router, showItemActions, posterSizes.poster], ); const renderItem = useCallback( diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx deleted file mode 100644 index fe41c147d..000000000 --- a/components/ContinueWatchingPoster.tv.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtomValue } from "jotai"; -import type React from "react"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { ProgressBar } from "./common/ProgressBar"; -import { WatchedIndicator } from "./WatchedIndicator"; - -type ContinueWatchingPosterProps = { - item: BaseItemDto; - useEpisodePoster?: boolean; - size?: "small" | "normal"; - showPlayButton?: boolean; -}; - -const ContinueWatchingPoster: React.FC = ({ - item, - useEpisodePoster = false, - // TV version uses fixed width, size prop kept for API compatibility - size: _size = "normal", - showPlayButton = false, -}) => { - const api = useAtomValue(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - if (!api) { - return; - } - if (item.Type === "Episode" && useEpisodePoster) { - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Episode") { - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Movie") { - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Program") { - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - }, [api, item, useEpisodePoster]); - - const progress = useMemo(() => { - if (item.Type === "Program") { - if (!item.StartDate || !item.EndDate) { - return 0; - } - const startDate = new Date(item.StartDate); - const endDate = new Date(item.EndDate); - const now = new Date(); - const total = endDate.getTime() - startDate.getTime(); - if (total <= 0) { - return 0; - } - const elapsed = now.getTime() - startDate.getTime(); - return (elapsed / total) * 100; - } - return item.UserData?.PlayedPercentage || 0; - }, [item]); - - const isWatched = item.UserData?.Played === true; - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (!url) { - return ( - - ); - } - - if (useGlass) { - return ( - - - {showPlayButton && ( - - - - )} - - ); - } - - // Fallback for older tvOS versions - return ( - - - - {showPlayButton && ( - - - - )} - - - - - ); -}; - -export default ContinueWatchingPoster; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index e92ea676e..b4bfb73a5 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -16,17 +16,15 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; -import ContinueWatchingPoster from "../ContinueWatchingPoster.tv"; -import SeriesPoster from "../posters/SeriesPoster.tv"; -const ITEM_GAP = 24; // Extra padding to accommodate scale animation (1.05x) and glow shadow const SCALE_PADDING = 20; @@ -47,76 +45,6 @@ interface Props extends ViewProps { } type Typography = ReturnType; - -// TV-specific ItemCardText with appropriately sized fonts -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; - width?: number; -}> = ({ item, typography, width }) => { - const renderSubtitle = () => { - if (item.Type === "Episode") { - return ( - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - ); - } - - if (item.Type === "Program") { - // For Live TV programs, show channel name - const channelName = item.ChannelName; - return channelName ? ( - - {channelName} - - ) : null; - } - - // Default: show production year - return item.ProductionYear ? ( - - {item.ProductionYear} - - ) : null; - }; - - return ( - - - {item.Name} - - {renderSubtitle()} - - ); -}; - type PosterSizes = ReturnType; // TV-specific "See All" card for end of lists @@ -139,7 +67,7 @@ const TVSeeAllCard: React.FC<{ }) => { const { t } = useTranslation(); const width = - orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; return ( @@ -200,6 +128,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); @@ -279,7 +209,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }, [data]); const itemWidth = - orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const handleItemPress = useCallback( (item: BaseItemDto) => { @@ -310,70 +240,17 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; - const isHorizontal = orientation === "horizontal"; - - const renderPoster = () => { - if (item.Type === "Episode" && isHorizontal) { - return ; - } - if (item.Type === "Episode" && !isHorizontal) { - return ; - } - if (item.Type === "Movie" && isHorizontal) { - return ; - } - if (item.Type === "Movie" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && isHorizontal) { - return ; - } - if (item.Type === "Program") { - return ; - } - if (item.Type === "BoxSet" && !isHorizontal) { - return ; - } - if (item.Type === "BoxSet" && isHorizontal) { - return ; - } - if (item.Type === "Playlist" && !isHorizontal) { - return ; - } - if (item.Type === "Playlist" && isHorizontal) { - return ; - } - if (item.Type === "Video" && !isHorizontal) { - return ; - } - if (item.Type === "Video" && isHorizontal) { - return ; - } - // Default fallback - return isHorizontal ? ( - - ) : ( - - ); - }; return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} - > - {renderPoster()} - - @@ -387,7 +264,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showItemActions, handleItemFocus, handleItemBlur, - typography, + ITEM_GAP, ], ); diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 07ba09b21..983cae574 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -11,10 +11,9 @@ import { FlatList, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -23,36 +22,8 @@ import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; -const ITEM_GAP = 16; const SCALE_PADDING = 20; -type Typography = ReturnType; - -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => { - return ( - - - {item.Name} - - - {item.ProductionYear} - - - ); -}; - interface WatchlistSectionProps extends ViewProps { watchlist: StreamystatsWatchlist; jellyfinServerId: string; @@ -67,6 +38,8 @@ const WatchlistSection: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -135,27 +108,31 @@ const WatchlistSection: React.FC = ({ offset: (posterSizes.poster + ITEM_GAP) * index, index, }), - [], + [posterSizes.poster, ITEM_GAP], ); const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} - hasTVPreferredFocus={false} - > - {item.Type === "Movie" && } - {item.Type === "Series" && } - - + width={posterSizes.poster} + /> ); }, - [handleItemPress, showItemActions, onItemFocus, typography], + [ + ITEM_GAP, + posterSizes.poster, + handleItemPress, + showItemActions, + onItemFocus, + ], ); if (!isLoading && (!items || items.length === 0)) return null; @@ -230,6 +207,8 @@ export const StreamystatsPromotedWatchlists: React.FC< StreamystatsPromotedWatchlistsProps > = ({ enabled = true, onItemFocus, ...props }) => { const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index c8a9cf0bd..b72d120d3 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -11,10 +11,8 @@ import { FlatList, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -23,11 +21,6 @@ import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; -const ITEM_GAP = 16; -const SCALE_PADDING = 20; - -type Typography = ReturnType; - interface Props extends ViewProps { title: string; type: "Movie" | "Series"; @@ -36,31 +29,6 @@ interface Props extends ViewProps { onItemFocus?: (item: BaseItemDto) => void; } -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => { - return ( - - - {item.Name} - - - {item.ProductionYear} - - - ); -}; - export const StreamystatsRecommendations: React.FC = ({ title, type, @@ -70,7 +38,7 @@ export const StreamystatsRecommendations: React.FC = ({ ...props }) => { const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -192,31 +160,29 @@ export const StreamystatsRecommendations: React.FC = ({ const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: posterSizes.poster + ITEM_GAP, - offset: (posterSizes.poster + ITEM_GAP) * index, + length: sizes.posters.poster + sizes.gaps.item, + offset: (sizes.posters.poster + sizes.gaps.item) * index, index, }), - [], + [sizes], ); const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} - hasTVPreferredFocus={false} - > - {item.Type === "Movie" && } - {item.Type === "Series" && } - - + width={sizes.posters.poster} + /> ); }, - [handleItemPress, showItemActions, onItemFocus, typography], + [sizes, handleItemPress, showItemActions, onItemFocus], ); if (!streamyStatsEnabled) return null; @@ -231,7 +197,7 @@ export const StreamystatsRecommendations: React.FC = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.scale, letterSpacing: 0.5, }} > @@ -242,17 +208,17 @@ export const StreamystatsRecommendations: React.FC = ({ {[1, 2, 3, 4, 5].map((i) => ( - + = ({ getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ - paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, + paddingVertical: sizes.padding.scale, + paddingHorizontal: sizes.padding.scale, }} /> )} diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 527bd74d5..0c3189050 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -23,7 +23,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { @@ -36,9 +36,6 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById" import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); -const HERO_HEIGHT = SCREEN_HEIGHT * 0.62; -const CARD_GAP = 24; -const CARD_PADDING = 60; interface TVHeroCarouselProps { items: BaseItemDto[]; @@ -49,14 +46,14 @@ interface TVHeroCarouselProps { interface HeroCardProps { item: BaseItemDto; isFirst: boolean; - cardWidth: number; + sizes: ScaledTVSizes; onFocus: (item: BaseItemDto) => void; onPress: (item: BaseItemDto) => void; onLongPress?: (item: BaseItemDto) => void; } const HeroCard: React.FC = React.memo( - ({ item, isFirst, cardWidth, onFocus, onPress, onLongPress }) => { + ({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => { const api = useAtomValue(apiAtom); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -87,8 +84,6 @@ const HeroCard: React.FC = React.memo( return null; }, [api, item]); - const progress = item.UserData?.PlayedPercentage || 0; - const animateTo = useCallback( (value: number) => Animated.timing(scale, { @@ -102,9 +97,9 @@ const HeroCard: React.FC = React.memo( const handleFocus = useCallback(() => { setFocused(true); - animateTo(1.1); + animateTo(sizes.animation.focusScale); onFocus(item); - }, [animateTo, onFocus, item]); + }, [animateTo, onFocus, item, sizes.animation.focusScale]); const handleBlur = useCallback(() => { setFocused(false); @@ -120,7 +115,8 @@ const HeroCard: React.FC = React.memo( }, [onLongPress, item]); // Use glass poster for tvOS 26+ - if (useGlass) { + if (useGlass && posterUrl) { + const progress = item.UserData?.PlayedPercentage || 0; return ( = React.memo( onFocus={handleFocus} onBlur={handleBlur} hasTVPreferredFocus={isFirst} - style={{ marginRight: CARD_GAP }} + style={{ marginRight: sizes.gaps.item }} > ); @@ -152,13 +148,13 @@ const HeroCard: React.FC = React.memo( onFocus={handleFocus} onBlur={handleBlur} hasTVPreferredFocus={isFirst} - style={{ marginRight: CARD_GAP }} + style={{ marginRight: sizes.gaps.item }} > = ({ onItemLongPress, }) => { const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -365,13 +361,13 @@ export const TVHeroCarousel: React.FC = ({ ), - [handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard], + [handleCardFocus, handleCardPress, onItemLongPress, sizes], ); // Memoize keyExtractor @@ -379,8 +375,10 @@ export const TVHeroCarousel: React.FC = ({ if (items.length === 0) return null; + const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; + return ( - + {/* Backdrop layers with crossfade */} = ({ @@ -624,7 +622,7 @@ export const TVHeroCarousel: React.FC = ({ keyExtractor={keyExtractor} showsHorizontalScrollIndicator={false} style={{ overflow: "visible" }} - contentContainerStyle={{ paddingVertical: 12 }} + contentContainerStyle={{ paddingVertical: sizes.gaps.small }} renderItem={renderHeroCard} removeClippedSubviews={false} initialNumToRender={8} diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index f7062c611..cab9d566f 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -26,11 +26,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { Loader } from "@/components/Loader"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVItemCardText } from "@/components/tv/TVItemCardText"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -43,7 +41,6 @@ const { width: SCREEN_WIDTH } = Dimensions.get("window"); const HORIZONTAL_PADDING = 80; const TOP_PADDING = 140; const ACTOR_IMAGE_SIZE = 250; -const ITEM_GAP = 16; const SCALE_PADDING = 20; interface TVActorPageProps { @@ -59,6 +56,8 @@ export const TVActorPage: React.FC = ({ personId }) => { const from = (segments as string[])[2] || "(home)"; const posterSizes = useScaledTVPosterSizes(); const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -243,19 +242,15 @@ export const TVActorPage: React.FC = ({ personId }) => { const renderMovieItem = useCallback( ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( - handleItemPress(filmItem)} onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={index === 0} - > - - - - - - - + width={posterSizes.poster} + /> ), [handleItemPress, showItemActions, posterSizes.poster], @@ -265,19 +260,15 @@ export const TVActorPage: React.FC = ({ personId }) => { const renderSeriesItem = useCallback( ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( - handleItemPress(filmItem)} onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={movies.length === 0 && index === 0} - > - - - - - - - + width={posterSizes.poster} + /> ), [handleItemPress, showItemActions, posterSizes.poster, movies.length], diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx deleted file mode 100644 index ed0475432..000000000 --- a/components/posters/MoviePoster.tv.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; - -type MoviePosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -const MoviePoster: React.FC = ({ - item, - showProgress = false, -}) => { - const [api] = useAtom(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - return getPrimaryImageUrl({ - api, - item, - width: posterSizes.poster * 2, // 2x for quality on large screens - }); - }, [api, item, posterSizes.poster]); - - const progress = item.UserData?.PlayedPercentage || 0; - const isWatched = item.UserData?.Played === true; - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (useGlass) { - return ( - - ); - } - - // Fallback for older tvOS versions - return ( - - - - {showProgress && progress > 0 && ( - - )} - - ); -}; - -export default MoviePoster; diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx deleted file mode 100644 index 125d9d3eb..000000000 --- a/components/posters/SeriesPoster.tv.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; - -type SeriesPosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -const SeriesPoster: React.FC = ({ item }) => { - const [api] = useAtom(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`; - } - return getPrimaryImageUrl({ - api, - item, - width: posterSizes.poster * 2, // 2x for quality on large screens - }); - }, [api, item, posterSizes.poster]); - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (useGlass) { - return ( - - ); - } - - // Fallback for older tvOS versions - return ( - - - - ); -}; - -export default SeriesPoster; diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx index 5c8192f4a..cba3a5548 100644 --- a/components/search/TVJellyseerrSearchResults.tsx +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -151,7 +151,7 @@ const TVJellyseerrPersonPoster: React.FC = ({ const typography = useScaledTVTypography(); const { jellyseerrApi } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation(); const posterUrl = item.profilePath ? jellyseerrApi?.imageProxy(item.profilePath, "w185") diff --git a/components/search/TVSearchBadge.tsx b/components/search/TVSearchBadge.tsx index 8e15eec0e..61b47d648 100644 --- a/components/search/TVSearchBadge.tsx +++ b/components/search/TVSearchBadge.tsx @@ -17,7 +17,7 @@ export const TVSearchBadge: React.FC = ({ }) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + useTVFocusAnimation({ duration: 150 }); return ( = ({ item }) => { - const typography = useScaledTVTypography(); - return ( - - {item.Type === "Episode" ? ( - <> - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - - ) : item.Type === "MusicArtist" ? ( - - {item.Name} - - ) : item.Type === "MusicAlbum" ? ( - <> - - {item.Name} - - - {item.AlbumArtist || item.Artists?.join(", ")} - - - ) : item.Type === "Audio" ? ( - <> - - {item.Name} - - - {item.Artists?.join(", ") || item.AlbumArtist} - - - ) : item.Type === "Playlist" ? ( - <> - - {item.Name} - - - {item.ChildCount} tracks - - - ) : item.Type === "Person" ? ( - - {item.Name} - - ) : ( - <> - - {item.Name} - - - {item.ProductionYear} - - - )} - - ); -}; - interface TVSearchSectionProps extends ViewProps { title: string; items: BaseItemDto[]; @@ -160,6 +35,8 @@ export const TVSearchSection: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -189,146 +66,176 @@ export const TVSearchSection: React.FC = ({ offset: (itemWidth + ITEM_GAP) * index, index, }), - [itemWidth], + [itemWidth, ITEM_GAP], ); const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; - const isHorizontal = orientation === "horizontal"; - const renderPoster = () => { - // Music Artist - circular avatar - if (item.Type === "MusicArtist") { - const imageUrl = imageUrlGetter?.(item); - return ( - + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} > - {imageUrl ? ( - - ) : ( - - 👤 - - )} + + {imageUrl ? ( + + ) : ( + + 👤 + + )} + + + + + {item.Name} + - ); - } - - // Music Album, Audio, Playlist - square images - if ( - item.Type === "MusicAlbum" || - item.Type === "Audio" || - item.Type === "Playlist" - ) { - const imageUrl = imageUrlGetter?.(item); - const icon = - item.Type === "Playlist" - ? "🎶" - : item.Type === "Audio" - ? "🎵" - : "🎵"; - return ( - - {imageUrl ? ( - - ) : ( - - {icon} - - )} - - ); - } - - // Person (Actor) - if (item.Type === "Person") { - return ; - } - - // Episode rendering - if (item.Type === "Episode" && isHorizontal) { - return ; - } - if (item.Type === "Episode" && !isHorizontal) { - return ; - } - - // Movie rendering - if (item.Type === "Movie" && isHorizontal) { - return ; - } - if (item.Type === "Movie" && !isHorizontal) { - return ; - } - - // Series rendering - if (item.Type === "Series" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && isHorizontal) { - return ; - } - - // BoxSet (Collection) - if (item.Type === "BoxSet" && !isHorizontal) { - return ; - } - if (item.Type === "BoxSet" && isHorizontal) { - return ; - } - - // Default fallback - return isHorizontal ? ( - - ) : ( - + ); - }; + } - // Special width for music artists (circular) - const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth; + // Special handling for MusicAlbum, Audio, Playlist (square images) + if ( + item.Type === "MusicAlbum" || + item.Type === "Audio" || + item.Type === "Playlist" + ) { + const imageUrl = imageUrlGetter?.(item); + const icon = + item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵"; + return ( + + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} + > + + {imageUrl ? ( + + ) : ( + + {icon} + + )} + + + + + {item.Name} + + {item.Type === "MusicAlbum" && ( + + {item.AlbumArtist || item.Artists?.join(", ")} + + )} + {item.Type === "Audio" && ( + + {item.Artists?.join(", ") || item.AlbumArtist} + + )} + {item.Type === "Playlist" && ( + + {item.ChildCount} tracks + + )} + + + ); + } + // Use TVPosterCard for all other item types return ( - - + onItemPress(item)} onLongPress={ onItemLongPress ? () => onItemLongPress(item) : undefined @@ -337,10 +244,8 @@ export const TVSearchSection: React.FC = ({ onFocus={handleItemFocus} onBlur={handleItemBlur} disabled={disabled} - > - {renderPoster()} - - + width={itemWidth} + /> ); }, @@ -354,6 +259,9 @@ export const TVSearchSection: React.FC = ({ handleItemBlur, disabled, imageUrlGetter, + posterSizes.poster, + typography.callout, + ITEM_GAP, ], ); diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx index d0d5f8573..d15d43ce2 100644 --- a/components/search/TVSearchTabBadges.tsx +++ b/components/search/TVSearchTabBadges.tsx @@ -23,7 +23,7 @@ const TVSearchTabBadge: React.FC = ({ }) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + useTVFocusAnimation({ duration: 150 }); // Design language: white for focused/selected, transparent white for unfocused const getBackgroundColor = () => { diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx deleted file mode 100644 index af1d8353a..000000000 --- a/components/series/TVEpisodeCard.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtomValue } from "jotai"; -import React, { useMemo } from "react"; -import { View } from "react-native"; -import { ProgressBar } from "@/components/common/ProgressBar"; -import { Text } from "@/components/common/Text"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { useScaledTVTypography } from "@/constants/TVTypography"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { runtimeTicksToMinutes } from "@/utils/time"; - -interface TVEpisodeCardProps { - episode: BaseItemDto; - hasTVPreferredFocus?: boolean; - disabled?: boolean; - /** When true, the item remains focusable even when disabled (for navigation purposes) */ - focusableWhenDisabled?: boolean; - /** Shows a "Now Playing" badge on the card */ - isCurrent?: boolean; - onPress: () => void; - onLongPress?: () => void; - onFocus?: () => void; - onBlur?: () => void; - /** Setter function for the ref (for focus guide destinations) */ - refSetter?: (ref: View | null) => void; -} - -export const TVEpisodeCard: React.FC = ({ - episode, - hasTVPreferredFocus = false, - disabled = false, - focusableWhenDisabled = false, - isCurrent = false, - onPress, - onLongPress, - onFocus, - onBlur, - refSetter, -}) => { - const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); - const api = useAtomValue(apiAtom); - - const thumbnailUrl = useMemo(() => { - if (!api) return null; - - // Try to get episode primary image first - if (episode.ImageTags?.Primary) { - return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`; - } - - // Fall back to series thumb or backdrop - if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) { - return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`; - } - - // Default episode image - return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`; - }, [api, episode]); - - const duration = useMemo(() => { - if (!episode.RunTimeTicks) return null; - return runtimeTicksToMinutes(episode.RunTimeTicks); - }, [episode.RunTimeTicks]); - - const episodeLabel = useMemo(() => { - const season = episode.ParentIndexNumber; - const ep = episode.IndexNumber; - if (season !== undefined && ep !== undefined) { - return `S${season}:E${ep}`; - } - return null; - }, [episode.ParentIndexNumber, episode.IndexNumber]); - - const progress = episode.UserData?.PlayedPercentage || 0; - const isWatched = episode.UserData?.Played === true; - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - // Now Playing badge component (shared between glass and fallback) - const NowPlayingBadge = isCurrent ? ( - - - - Now Playing - - - ) : null; - - return ( - - - {useGlass ? ( - - - {NowPlayingBadge} - - ) : ( - - {thumbnailUrl ? ( - - ) : ( - - )} - - - {NowPlayingBadge} - - )} - - - {/* Episode info below thumbnail */} - - - {episodeLabel && ( - - {episodeLabel} - - )} - {duration && ( - <> - - • - - - {duration} - - - )} - - - {episode.Name} - - - - ); -}; diff --git a/components/series/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx index 5e48e9c13..0f2713208 100644 --- a/components/series/TVEpisodeList.tsx +++ b/components/series/TVEpisodeList.tsx @@ -1,12 +1,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import React from "react"; +import React, { useCallback } from "react"; import { ScrollView, View } from "react-native"; -import { Text } from "@/components/common/Text"; -import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; -import { useScaledTVTypography } from "@/constants/TVTypography"; - -const LIST_GAP = 24; -const VERTICAL_PADDING = 12; +import { TVHorizontalList } from "@/components/tv/TVHorizontalList"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; interface TVEpisodeListProps { episodes: BaseItemDto[]; @@ -28,7 +24,7 @@ interface TVEpisodeListProps { firstEpisodeRefSetter?: (ref: View | null) => void; /** Text to show when episodes array is empty */ emptyText?: string; - /** Horizontal padding for the list content (default: 80) */ + /** Horizontal padding for the list content */ horizontalPadding?: number; } @@ -43,57 +39,51 @@ export const TVEpisodeList: React.FC = ({ scrollViewRef, firstEpisodeRefSetter, emptyText, - horizontalPadding = 80, + horizontalPadding, }) => { - const typography = useScaledTVTypography(); + const renderItem = useCallback( + ({ item: episode, index }: { item: BaseItemDto; index: number }) => { + const isCurrent = currentEpisodeId + ? episode.Id === currentEpisodeId + : false; + return ( + onEpisodePress(episode)} + onLongPress={ + onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined + } + onFocus={onFocus} + onBlur={onBlur} + disabled={isCurrent || disabled} + focusableWhenDisabled={isCurrent} + isCurrent={isCurrent} + refSetter={index === 0 ? firstEpisodeRefSetter : undefined} + /> + ); + }, + [ + currentEpisodeId, + disabled, + firstEpisodeRefSetter, + onBlur, + onEpisodeLongPress, + onEpisodePress, + onFocus, + ], + ); - if (episodes.length === 0 && emptyText) { - return ( - - {emptyText} - - ); - } + const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []); return ( - } - horizontal - showsHorizontalScrollIndicator={false} - style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }} - contentContainerStyle={{ - paddingHorizontal: horizontalPadding, - paddingVertical: VERTICAL_PADDING, - gap: LIST_GAP, - }} - > - {episodes.map((episode, index) => { - const isCurrent = currentEpisodeId - ? episode.Id === currentEpisodeId - : false; - return ( - onEpisodePress(episode)} - onLongPress={ - onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined - } - onFocus={onFocus} - onBlur={onBlur} - disabled={isCurrent || disabled} - focusableWhenDisabled={isCurrent} - isCurrent={isCurrent} - refSetter={index === 0 ? firstEpisodeRefSetter : undefined} - /> - ); - })} - + ); }; diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index aec682e9e..29817512d 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -21,7 +21,7 @@ export const TVActorCard = React.forwardRef( ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation(); const imageUrl = person.Id ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90` diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx index f1f1276b5..95c5f3d13 100644 --- a/components/tv/TVCastSection.tsx +++ b/components/tv/TVCastSection.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, TVFocusGuideView, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVActorCard } from "./TVActorCard"; @@ -25,6 +26,7 @@ export const TVCastSection: React.FC = React.memo( upwardFocusDestination, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { t } = useTranslation(); if (cast.length === 0) { @@ -57,7 +59,7 @@ export const TVCastSection: React.FC = React.memo( contentContainerStyle={{ paddingHorizontal: 80, paddingVertical: 16, - gap: 28, + gap: sizes.gaps.item, }} > {cast.map((person, index) => ( diff --git a/components/tv/TVHorizontalList.tsx b/components/tv/TVHorizontalList.tsx new file mode 100644 index 000000000..87a2db73a --- /dev/null +++ b/components/tv/TVHorizontalList.tsx @@ -0,0 +1,221 @@ +import React, { useCallback } from "react"; +import { FlatList, ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVHorizontalListProps { + /** Data items to render */ + data: T[]; + /** Unique key extractor */ + keyExtractor: (item: T, index: number) => string; + /** Render function for each item */ + renderItem: (info: { item: T; index: number }) => React.ReactElement | null; + /** Optional section title */ + title?: string; + /** Text to show when data array is empty */ + emptyText?: string; + /** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */ + useFlatList?: boolean; + /** Called when end is reached (only for FlatList) */ + onEndReached?: () => void; + /** Ref for the scroll view */ + scrollViewRef?: React.RefObject | null>; + /** Footer component (only for FlatList) */ + ListFooterComponent?: React.ReactElement | null; + /** Whether this is the first section (for initial focus) */ + isFirstSection?: boolean; + /** Loading state */ + isLoading?: boolean; + /** Skeleton item count when loading */ + skeletonCount?: number; + /** Skeleton render function */ + renderSkeleton?: () => React.ReactElement; + /** + * Custom horizontal padding (overrides default sizes.padding.scale). + * Use this when the list needs to extend beyond its parent's padding. + * The list will use negative margin to extend beyond the parent, + * then add this padding inside to align content properly. + */ + horizontalPadding?: number; +} + +/** + * TVHorizontalList - A unified horizontal list component for TV. + * + * Provides consistent spacing and layout for horizontal lists: + * - Uses `sizes.gaps.item` (24px default) for gap between items + * - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale + * - Supports both ScrollView (small lists) and FlatList (large/infinite lists) + */ +export function TVHorizontalList({ + data, + keyExtractor, + renderItem, + title, + emptyText, + useFlatList = false, + onEndReached, + scrollViewRef, + ListFooterComponent, + isLoading = false, + skeletonCount = 5, + renderSkeleton, + horizontalPadding, +}: TVHorizontalListProps) { + const sizes = useScaledTVSizes(); + const typography = useScaledTVTypography(); + + // Use custom horizontal padding if provided, otherwise use default scale padding + const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale; + // Apply negative margin when using custom padding to extend beyond parent + const marginHorizontal = horizontalPadding ? -horizontalPadding : 0; + + // Wrap renderItem to add consistent gap + const renderItemWithGap = useCallback( + ({ item, index }: { item: T; index: number }) => { + const isLast = index === data.length - 1; + return ( + + {renderItem({ item, index })} + + ); + }, + [data.length, renderItem, sizes.gaps.item], + ); + + // Empty state + if (!isLoading && data.length === 0 && emptyText) { + return ( + + {title && ( + + {title} + + )} + + {emptyText} + + + ); + } + + // Loading state + if (isLoading && renderSkeleton) { + return ( + + {title && ( + + {title} + + )} + + {Array.from({ length: skeletonCount }).map((_, i) => ( + {renderSkeleton()} + ))} + + + ); + } + + const contentContainerStyle = { + paddingHorizontal: effectiveHorizontalPadding, + paddingVertical: sizes.padding.scale, + }; + + const listStyle = { + overflow: "visible" as const, + marginHorizontal, + }; + + return ( + + {title && ( + + {title} + + )} + + {useFlatList ? ( + >} + horizontal + data={data} + keyExtractor={keyExtractor} + renderItem={renderItemWithGap} + showsHorizontalScrollIndicator={false} + removeClippedSubviews={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListFooterComponent={ListFooterComponent} + /> + ) : ( + } + horizontal + showsHorizontalScrollIndicator={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + > + {data.map((item, index) => ( + + {renderItem({ item, index })} + + ))} + {ListFooterComponent} + + )} + + ); +} diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx index 338137753..5414dde73 100644 --- a/components/tv/TVSeriesNavigation.tsx +++ b/components/tv/TVSeriesNavigation.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; @@ -17,6 +18,7 @@ export interface TVSeriesNavigationProps { export const TVSeriesNavigation: React.FC = React.memo( ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { t } = useTranslation(); // Only show for episodes with a series @@ -25,13 +27,14 @@ export const TVSeriesNavigation: React.FC = React.memo( } return ( - + {t("item_card.from_this_series") || "From this Series"} @@ -39,11 +42,14 @@ export const TVSeriesNavigation: React.FC = React.memo( {/* Series card */} diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx index 3522f711f..a2a3e565f 100644 --- a/components/tv/settings/TVSettingsToggle.tsx +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -57,7 +57,7 @@ export const TVSettingsToggle: React.FC = ({ width: 56, height: 32, borderRadius: 16, - backgroundColor: value ? "#34C759" : "#4B5563", + backgroundColor: value ? "#FFFFFF" : "#4B5563", justifyContent: "center", paddingHorizontal: 2, }} @@ -67,7 +67,7 @@ export const TVSettingsToggle: React.FC = ({ width: 28, height: 28, borderRadius: 14, - backgroundColor: "#FFFFFF", + backgroundColor: value ? "#000000" : "#FFFFFF", alignSelf: value ? "flex-end" : "flex-start", }} /> diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts index 06f661dbd..cec24162a 100644 --- a/components/video-player/controls/constants.ts +++ b/components/video-player/controls/constants.ts @@ -7,6 +7,7 @@ export const CONTROLS_CONSTANTS = { PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks LONG_PRESS_INITIAL_SEEK: 30, LONG_PRESS_ACCELERATION: 1.2, + LONG_PRESS_MAX_ACCELERATION: 4, LONG_PRESS_INTERVAL: 300, SLIDER_DEBOUNCE_MS: 3, } as const; diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts index 5295adef7..132b75a66 100644 --- a/constants/TVPosterSizes.ts +++ b/constants/TVPosterSizes.ts @@ -1,57 +1,12 @@ -import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; - /** - * TV Poster Sizes - * - * Base sizes for poster components on TV interfaces. - * These are scaled dynamically based on the user's tvTypographyScale setting. + * @deprecated Import from "@/constants/TVSizes" instead. + * This file is kept for backwards compatibility. */ -export const TVPosterSizes = { - /** Portrait posters (movies, series) - 10:15 aspect ratio */ - poster: 256, +export { + type ScaledTVPosterSizes, + TVPosterSizes, + useScaledTVPosterSizes, +} from "./TVSizes"; - /** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */ - landscape: 396, - - /** Episode cards - 16:9 aspect ratio */ - episode: 336, - - /** Hero carousel cards - 16:9 aspect ratio */ - heroCard: 276, -} as const; - -export type TVPosterSizeKey = keyof typeof TVPosterSizes; - -/** - * Linear poster size offsets (in pixels) - synchronized with typography scale. - * Uses fixed pixel steps for consistent linear scaling across all poster types. - */ -const posterScaleOffsets: Record = { - [TVTypographyScale.Small]: -10, - [TVTypographyScale.Default]: 0, - [TVTypographyScale.Large]: 10, - [TVTypographyScale.ExtraLarge]: 20, -}; - -/** - * Hook that returns scaled TV poster sizes based on user settings. - * Use this instead of the static TVPosterSizes constant for dynamic scaling. - * - * @example - * const posterSizes = useScaledTVPosterSizes(); - * - */ -export const useScaledTVPosterSizes = () => { - const { settings } = useSettings(); - const offset = - posterScaleOffsets[settings.tvTypographyScale] ?? - posterScaleOffsets[TVTypographyScale.Default]; - - return { - poster: TVPosterSizes.poster + offset, - landscape: TVPosterSizes.landscape + offset, - episode: TVPosterSizes.episode + offset, - heroCard: TVPosterSizes.heroCard + offset, - }; -}; +export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes; diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts new file mode 100644 index 000000000..20c38daa9 --- /dev/null +++ b/constants/TVSizes.ts @@ -0,0 +1,175 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; + +/** + * TV Layout Sizes + * + * Unified constants for TV interface layout including posters, gaps, and padding. + * All values scale based on the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +/** + * Base poster widths in pixels. + * Heights are calculated from aspect ratios. + */ +export const TVPosterSizes = { + /** Portrait posters (movies, series) - 10:15 aspect ratio */ + poster: 210, + + /** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */ + landscape: 340, + + /** Episode cards - 16:9 aspect ratio */ + episode: 320, +} as const; + +/** + * Base gap/spacing values in pixels. + */ +export const TVGaps = { + /** Gap between items in horizontal lists */ + item: 24, + + /** Gap between sections vertically */ + section: 32, + + /** Small gap for tight layouts */ + small: 12, + + /** Large gap for spacious layouts */ + large: 48, +} as const; + +/** + * Base padding values in pixels. + */ +export const TVPadding = { + /** Horizontal padding from screen edges */ + horizontal: 60, + + /** Padding to accommodate scale animations (1.05x) */ + scale: 20, + + /** Vertical padding for content areas */ + vertical: 24, + + /** Hero section height as percentage of screen height (0.0 - 1.0) */ + heroHeight: 0.6, +} as const; + +/** + * Animation and interaction values. + */ +export const TVAnimation = { + /** Scale factor for focused items */ + focusScale: 1.05, +} as const; + +// ============================================================================= +// SCALING +// ============================================================================= + +/** + * Scale multipliers for each typography scale level. + * Applied to poster sizes and gaps. + */ +const sizeScaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.9, + [TVTypographyScale.Default]: 1.0, + [TVTypographyScale.Large]: 1.1, + [TVTypographyScale.ExtraLarge]: 1.2, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVPosterSizes = { + poster: number; + landscape: number; + episode: number; +}; + +export type ScaledTVGaps = { + item: number; + section: number; + small: number; + large: number; +}; + +export type ScaledTVPadding = { + horizontal: number; + scale: number; + vertical: number; + heroHeight: number; +}; + +export type ScaledTVSizes = { + posters: ScaledTVPosterSizes; + gaps: ScaledTVGaps; + padding: ScaledTVPadding; + animation: typeof TVAnimation; +}; + +/** + * Hook that returns all scaled TV sizes based on user settings. + * + * @example + * const sizes = useScaledTVSizes(); + * + */ +export const useScaledTVSizes = (): ScaledTVSizes => { + const { settings } = useSettings(); + const scale = + sizeScaleMultipliers[settings.tvTypographyScale] ?? + sizeScaleMultipliers[TVTypographyScale.Default]; + + return { + posters: { + poster: Math.round(TVPosterSizes.poster * scale), + landscape: Math.round(TVPosterSizes.landscape * scale), + episode: Math.round(TVPosterSizes.episode * scale), + }, + gaps: { + item: Math.round(TVGaps.item * scale), + section: Math.round(TVGaps.section * scale), + small: Math.round(TVGaps.small * scale), + large: Math.round(TVGaps.large * scale), + }, + padding: { + horizontal: Math.round(TVPadding.horizontal * scale), + scale: Math.round(TVPadding.scale * scale), + vertical: Math.round(TVPadding.vertical * scale), + heroHeight: TVPadding.heroHeight * scale, + }, + animation: TVAnimation, + }; +}; + +/** + * Hook that returns only scaled poster sizes. + * Use this for backwards compatibility or when you only need poster sizes. + */ +export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => { + const sizes = useScaledTVSizes(); + return sizes.posters; +}; + +/** + * Hook that returns only scaled gap sizes. + */ +export const useScaledTVGaps = (): ScaledTVGaps => { + const sizes = useScaledTVSizes(); + return sizes.gaps; +}; + +/** + * Hook that returns only scaled padding sizes. + */ +export const useScaledTVPadding = (): ScaledTVPadding => { + const sizes = useScaledTVSizes(); + return sizes.padding; +}; diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift index 77c5efb83..b68cdb979 100644 --- a/modules/glass-poster/ios/GlassPosterView.swift +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -59,7 +59,7 @@ struct GlassPosterView: View { .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .focusable() .focused($isInternallyFocused) - .scaleEffect(isCurrentlyFocused ? 1.08 : 1.0) + .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused) } #endif @@ -87,7 +87,7 @@ struct GlassPosterView: View { } } .frame(width: width, height: height) - .scaleEffect(isFocused ? 1.08 : 1.0) + .scaleEffect(isFocused ? 1.05 : 1.0) .animation(.easeOut(duration: 0.15), value: isFocused) } From 3814237ac6406f6b5d9b4cc36b1462ef41df737d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 09:16:01 +0100 Subject: [PATCH 149/309] fix(glass-poster): prevent image overflow on tvOS 26 --- modules/glass-poster/ios/GlassPosterView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift index b68cdb979..3efa9d4c3 100644 --- a/modules/glass-poster/ios/GlassPosterView.swift +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -104,6 +104,8 @@ struct GlassPosterView: View { image .resizable() .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) + .clipped() case .failure: placeholderView @unknown default: From 28e3060ace21179ceaaefbe2f25e89081ac12cd5 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 18:02:32 +0100 Subject: [PATCH 150/309] feat(player): add chapter navigation support with visual markers --- components/tv/TVFocusableProgressBar.tsx | 61 +++++-- .../video-player/controls/BottomControls.tsx | 7 + .../video-player/controls/CenterControls.tsx | 39 +++++ .../video-player/controls/ChapterMarkers.tsx | 65 ++++++++ components/video-player/controls/Controls.tsx | 22 +++ .../video-player/controls/Controls.tv.tsx | 110 ++++++++++--- .../video-player/controls/hooks/index.ts | 1 + .../controls/hooks/useChapterNavigation.ts | 150 ++++++++++++++++++ 8 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 components/video-player/controls/ChapterMarkers.tsx create mode 100644 components/video-player/controls/hooks/useChapterNavigation.ts diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx index e33e44445..8d6a48884 100644 --- a/components/tv/TVFocusableProgressBar.tsx +++ b/components/tv/TVFocusableProgressBar.tsx @@ -19,6 +19,8 @@ export interface TVFocusableProgressBarProps { max: SharedValue; /** Cache progress value (SharedValue) in milliseconds */ cacheProgress?: SharedValue; + /** Chapter positions as percentages (0-100) for tick marks */ + chapterPositions?: number[]; /** Callback when the progress bar receives focus */ onFocus?: () => void; /** Callback when the progress bar loses focus */ @@ -41,6 +43,7 @@ export const TVFocusableProgressBar: React.FC = progress, max, cacheProgress, + chapterPositions = [], onFocus, onBlur, refSetter, @@ -81,20 +84,36 @@ export const TVFocusableProgressBar: React.FC = focused && styles.animatedContainerFocused, ]} > - - {cacheProgress && ( + + + {cacheProgress && ( + + )} + + {/* Chapter markers - positioned outside track to extend above */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + )} - @@ -121,6 +140,10 @@ const styles = StyleSheet.create({ shadowOpacity: 0.5, shadowRadius: 12, }, + progressTrackWrapper: { + position: "relative", + height: PROGRESS_BAR_HEIGHT, + }, progressTrack: { height: PROGRESS_BAR_HEIGHT, backgroundColor: "rgba(255,255,255,0.2)", @@ -147,4 +170,20 @@ const styles = StyleSheet.create({ backgroundColor: "#fff", borderRadius: 8, }, + chapterMarkersContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + chapterMarker: { + position: "absolute", + width: 2, + height: PROGRESS_BAR_HEIGHT + 5, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.6)", + borderRadius: 1, + transform: [{ translateX: -1 }], + }, }); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c7..e4f264929 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -6,6 +6,7 @@ import { type SharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useSettings } from "@/utils/atoms/settings"; +import { ChapterMarkers } from "./ChapterMarkers"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; @@ -57,6 +58,9 @@ interface BottomControlsProps { minutes: number; seconds: number; }; + + // Chapter props + chapterPositions?: number[]; } export const BottomControls: FC = ({ @@ -87,6 +91,7 @@ export const BottomControls: FC = ({ trickPlayUrl, trickplayInfo, time, + chapterPositions = [], }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -176,6 +181,7 @@ export const BottomControls: FC = ({ height: 10, justifyContent: "center", alignItems: "stretch", + position: "relative", }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} @@ -212,6 +218,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + void; handleSkipBackward: () => void; handleSkipForward: () => void; + // Chapter navigation props + hasChapters?: boolean; + hasPreviousChapter?: boolean; + hasNextChapter?: boolean; + goToPreviousChapter?: () => void; + goToNextChapter?: () => void; } export const CenterControls: FC = ({ @@ -29,6 +35,11 @@ export const CenterControls: FC = ({ togglePlay, handleSkipBackward, handleSkipForward, + hasChapters = false, + hasPreviousChapter = false, + hasNextChapter = false, + goToPreviousChapter, + goToNextChapter, }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -94,6 +105,20 @@ export const CenterControls: FC = ({ )} + {!Platform.isTV && hasChapters && ( + + + + )} + {!isBuffering ? ( @@ -108,6 +133,20 @@ export const CenterControls: FC = ({ + {!Platform.isTV && hasChapters && ( + + + + )} + {!Platform.isTV && ( track height to extend above) */ + markerHeight?: number; + /** Color of the marker lines */ + markerColor?: string; +} + +/** + * Renders vertical tick marks on the progress bar at chapter positions + * Should be overlaid on the slider track + */ +export const ChapterMarkers: React.FC = React.memo( + ({ + chapterPositions, + style, + markerHeight = 15, + markerColor = "rgba(255, 255, 255, 0.6)", + }) => { + if (!chapterPositions.length) { + return null; + } + + return ( + + {chapterPositions.map((position, index) => ( + + ))} + + ); + }, +); + +const styles = StyleSheet.create({ + container: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + marker: { + position: "absolute", + width: 2, + borderRadius: 1, + transform: [{ translateX: -1 }], // Center the marker on its position + }, +}); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 1f1b37cca..de6326eae 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -33,6 +33,7 @@ import { CONTROLS_CONSTANTS } from "./constants"; import { EpisodeList } from "./EpisodeList"; import { GestureOverlay } from "./GestureOverlay"; import { HeaderControls } from "./HeaderControls"; +import { useChapterNavigation } from "./hooks/useChapterNavigation"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoSlider } from "./hooks/useVideoSlider"; @@ -211,6 +212,21 @@ export const Controls: FC = ({ isSeeking, }); + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + chapterPositions, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, + }); + const toggleControls = useCallback(() => { if (showControls) { setShowAudioSlider(false); @@ -526,6 +542,11 @@ export const Controls: FC = ({ togglePlay={togglePlay} handleSkipBackward={handleSkipBackward} handleSkipForward={handleSkipForward} + hasChapters={hasChapters} + hasPreviousChapter={hasPreviousChapter} + hasNextChapter={hasNextChapter} + goToPreviousChapter={goToPreviousChapter} + goToNextChapter={goToNextChapter} /> = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} + chapterPositions={chapterPositions} /> diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 42b584a0f..69b91790d 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -45,6 +45,7 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings" import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; import { CONTROLS_CONSTANTS } from "./constants"; import { useVideoContext } from "./contexts/VideoContext"; +import { useChapterNavigation } from "./hooks/useChapterNavigation"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoTime } from "./hooks/useVideoTime"; import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; @@ -375,6 +376,21 @@ export const Controls: FC = ({ isSeeking, }); + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + chapterPositions, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, + }); + // Countdown logic - needs to be early so toggleControls can reference it const isCountdownActive = useMemo(() => { if (!nextItem) return false; @@ -1038,23 +1054,44 @@ export const Controls: FC = ({ - - ({ - width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - ({ - width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + {/* Chapter markers */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + + )} @@ -1135,6 +1172,14 @@ export const Controls: FC = ({ disabled={!previousItem} size={28} /> + {hasChapters && ( + + )} = ({ lastOpenedModal === null } /> + {hasChapters && ( + + )} = ({ progress={effectiveProgress} max={max} cacheProgress={cacheProgress} + chapterPositions={chapterPositions} onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} @@ -1310,7 +1364,7 @@ const styles = StyleSheet.create({ }, trickplayBubbleContainer: { position: "absolute", - bottom: 170, + bottom: 190, left: 0, right: 0, zIndex: 20, @@ -1392,4 +1446,24 @@ const styles = StyleSheet.create({ // Brighter track like focused state backgroundColor: "rgba(255,255,255,0.35)", }, + minimalProgressTrackWrapper: { + position: "relative", + height: TV_SEEKBAR_HEIGHT, + }, + minimalChapterMarkersContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + minimalChapterMarker: { + position: "absolute", + width: 2, + height: TV_SEEKBAR_HEIGHT + 5, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.6)", + borderRadius: 1, + transform: [{ translateX: -1 }], + }, }); diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts index 08b234ac5..cfb317599 100644 --- a/components/video-player/controls/hooks/index.ts +++ b/components/video-player/controls/hooks/index.ts @@ -1,3 +1,4 @@ +export { useChapterNavigation } from "./useChapterNavigation"; export { useRemoteControl } from "./useRemoteControl"; export { useVideoNavigation } from "./useVideoNavigation"; export { useVideoSlider } from "./useVideoSlider"; diff --git a/components/video-player/controls/hooks/useChapterNavigation.ts b/components/video-player/controls/hooks/useChapterNavigation.ts new file mode 100644 index 000000000..00d3330c0 --- /dev/null +++ b/components/video-player/controls/hooks/useChapterNavigation.ts @@ -0,0 +1,150 @@ +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback, useMemo } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import { ticksToMs } from "@/utils/time"; + +export interface UseChapterNavigationProps { + /** Chapters array from the item */ + chapters: ChapterInfo[] | null | undefined; + /** Current progress in milliseconds (SharedValue) */ + progress: SharedValue; + /** Total duration in milliseconds */ + maxMs: number; + /** Seek function that accepts milliseconds */ + seek: (ms: number) => void; +} + +export interface UseChapterNavigationReturn { + /** Array of chapters */ + chapters: ChapterInfo[]; + /** Index of the current chapter (-1 if no chapters) */ + currentChapterIndex: number; + /** Current chapter info or null */ + currentChapter: ChapterInfo | null; + /** Whether there's a next chapter available */ + hasNextChapter: boolean; + /** Whether there's a previous chapter available */ + hasPreviousChapter: boolean; + /** Navigate to the next chapter */ + goToNextChapter: () => void; + /** Navigate to the previous chapter (or restart current if >3s in) */ + goToPreviousChapter: () => void; + /** Array of chapter positions as percentages (0-100) for tick marks */ + chapterPositions: number[]; + /** Whether chapters are available */ + hasChapters: boolean; +} + +// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous +const RESTART_THRESHOLD_MS = 3000; + +/** + * Hook for chapter navigation in video player + * Provides current chapter info and navigation functions + */ +export function useChapterNavigation({ + chapters: rawChapters, + progress, + maxMs, + seek, +}: UseChapterNavigationProps): UseChapterNavigationReturn { + // Ensure chapters is always an array + const chapters = useMemo(() => rawChapters ?? [], [rawChapters]); + + // Calculate chapter positions as percentages for tick marks + const chapterPositions = useMemo(() => { + if (!chapters.length || maxMs <= 0) return []; + + return chapters + .map((chapter) => { + const positionMs = ticksToMs(chapter.StartPositionTicks); + return (positionMs / maxMs) * 100; + }) + .filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end + }, [chapters, maxMs]); + + // Find current chapter index based on progress + // The current chapter is the one with the largest StartPositionTicks that is <= current progress + const getCurrentChapterIndex = useCallback((): number => { + if (!chapters.length) return -1; + + const currentMs = progress.value; + let currentIndex = -1; + + for (let i = 0; i < chapters.length; i++) { + const chapterMs = ticksToMs(chapters[i].StartPositionTicks); + if (chapterMs <= currentMs) { + currentIndex = i; + } else { + break; + } + } + + return currentIndex; + }, [chapters, progress]); + + // Current chapter index (computed once for rendering) + const currentChapterIndex = getCurrentChapterIndex(); + + // Current chapter info + const currentChapter = useMemo(() => { + if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) { + return null; + } + return chapters[currentChapterIndex]; + }, [chapters, currentChapterIndex]); + + // Navigation availability + const hasNextChapter = + chapters.length > 0 && currentChapterIndex < chapters.length - 1; + const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0; + + // Navigate to next chapter + const goToNextChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < chapters.length - 1) { + const nextChapter = chapters[idx + 1]; + const nextMs = ticksToMs(nextChapter.StartPositionTicks); + progress.value = nextMs; + seek(nextMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + // Navigate to previous chapter (or restart current if >3s in) + const goToPreviousChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < 0) return; + + const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks); + const currentMs = progress.value; + const timeIntoChapter = currentMs - currentChapterMs; + + // If more than 3 seconds into the current chapter, restart it + // Otherwise, go to the previous chapter + if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) { + progress.value = currentChapterMs; + seek(currentChapterMs); + } else if (idx > 0) { + const prevChapter = chapters[idx - 1]; + const prevMs = ticksToMs(prevChapter.StartPositionTicks); + progress.value = prevMs; + seek(prevMs); + } else { + // At the first chapter, just restart it + progress.value = currentChapterMs; + seek(currentChapterMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + return { + chapters, + currentChapterIndex, + currentChapter, + hasNextChapter, + hasPreviousChapter, + goToNextChapter, + goToPreviousChapter, + chapterPositions, + hasChapters: chapters.length > 0, + }; +} From af2cac0e86df1938fa9a6a63a60758733a2663b8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 18:52:22 +0100 Subject: [PATCH 151/309] feat(player): add skip intro/credits support for tvOS --- app/(auth)/player/direct-player.tsx | 1 + components/ItemContent.tv.tsx | 64 ++++++-- components/tv/TVSkipSegmentCard.tsx | 139 ++++++++++++++++++ components/tv/index.ts | 2 + .../video-player/controls/Controls.tv.tsx | 138 ++++++++++++++--- .../controls/hooks/useRemoteControl.ts | 9 +- translations/en.json | 10 +- 7 files changed, 325 insertions(+), 38 deletions(-) create mode 100644 components/tv/TVSkipSegmentCard.tsx diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 00f3e74fb..5725c56cc 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -1198,6 +1198,7 @@ export default function page() { getTechnicalInfo={getTechnicalInfo} playMethod={playMethod} transcodeReasons={transcodeReasons} + downloadedFiles={downloadedFiles} /> ) : ( = React.memo( defaultMediaSource, ]); + const navigateToPlayer = useCallback( + (playbackPosition: string) => { + if (!item || !selectedOptions) return; + + const queryParams = new URLSearchParams({ + itemId: item.Id!, + audioIndex: selectedOptions.audioIndex?.toString() ?? "", + subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", + mediaSourceId: selectedOptions.mediaSource?.Id ?? "", + bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition, + offline: isOffline ? "true" : "false", + }); + + router.push(`/player/direct-player?${queryParams.toString()}`); + }, + [item, selectedOptions, isOffline, router], + ); + const handlePlay = () => { if (!item || !selectedOptions) return; - const queryParams = new URLSearchParams({ - itemId: item.Id!, - audioIndex: selectedOptions.audioIndex?.toString() ?? "", - subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", - mediaSourceId: selectedOptions.mediaSource?.Id ?? "", - bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "0", - offline: isOffline ? "true" : "false", - }); + const hasPlaybackProgress = + (item.UserData?.PlaybackPositionTicks ?? 0) > 0; - router.push(`/player/direct-player?${queryParams.toString()}`); + if (hasPlaybackProgress) { + Alert.alert( + t("item_card.resume_playback"), + t("item_card.resume_playback_description"), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("item_card.play_from_start"), + onPress: () => navigateToPlayer("0"), + }, + { + text: t("item_card.continue_from", { + time: formatDuration(item.UserData?.PlaybackPositionTicks), + }), + onPress: () => + navigateToPlayer( + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + ), + isPreferred: true, + }, + ], + ); + } else { + navigateToPlayer("0"); + } }; // TV Option Modal hook for quality, audio, media source selectors diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx new file mode 100644 index 000000000..f735e7d52 --- /dev/null +++ b/components/tv/TVSkipSegmentCard.tsx @@ -0,0 +1,139 @@ +import { Ionicons } from "@expo/vector-icons"; +import { type FC, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + View, +} from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVSkipSegmentCardProps { + show: boolean; + onPress: () => void; + type: "intro" | "credits"; + /** Whether this card should capture focus when visible */ + hasFocus?: boolean; + /** Whether controls are visible - affects card position */ + controlsVisible?: boolean; +} + +// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive) +const BOTTOM_WITH_CONTROLS = 300; +const BOTTOM_WITHOUT_CONTROLS = 120; + +export const TVSkipSegmentCard: FC = ({ + show, + onPress, + type, + hasFocus = false, + controlsVisible = false, +}) => { + const { t } = useTranslation(); + const pressableRef = useRef(null); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.1, + duration: 120, + }); + + // Programmatically request focus when card appears with hasFocus=true + useEffect(() => { + if (!show || !hasFocus || !pressableRef.current) return; + + const timer = setTimeout(() => { + // Use setNativeProps to trigger focus update on tvOS + (pressableRef.current as any)?.setNativeProps?.({ + hasTVPreferredFocus: true, + }); + }, 50); + return () => clearTimeout(timer); + }, [show, hasFocus]); + + // Animated position based on controls visibility + const bottomPosition = useSharedValue( + controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, + ); + + useEffect(() => { + const target = controlsVisible + ? BOTTOM_WITH_CONTROLS + : BOTTOM_WITHOUT_CONTROLS; + bottomPosition.value = withTiming(target, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [controlsVisible, bottomPosition]); + + const containerAnimatedStyle = useAnimatedStyle(() => ({ + bottom: bottomPosition.value, + })); + + const labelText = + type === "intro" ? t("player.skip_intro") : t("player.skip_credits"); + + if (!show) return null; + + return ( + + + + + {labelText} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + right: 80, + zIndex: 100, + }, + button: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 10, + paddingHorizontal: 18, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + label: { + fontSize: 20, + color: "#fff", + fontWeight: "600", + }, +}); diff --git a/components/tv/index.ts b/components/tv/index.ts index 527ef22e1..76a6e60e9 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -53,6 +53,8 @@ export type { TVSeriesNavigationProps } from "./TVSeriesNavigation"; export { TVSeriesNavigation } from "./TVSeriesNavigation"; export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard"; export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; +export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard"; +export { TVSkipSegmentCard } from "./TVSkipSegmentCard"; export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard"; export { TVSubtitleResultCard } from "./TVSubtitleResultCard"; export type { TVTabButtonProps } from "./TVTabButton"; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 69b91790d..81ab15421 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -29,16 +29,24 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv"; +import { + TVControlButton, + TVNextEpisodeCountdown, + TVSkipSegmentCard, +} from "@/components/tv"; import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useCreditSkipper } from "@/hooks/useCreditSkipper"; +import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import type { TechnicalInfo } from "@/modules/mpv-player"; +import type { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; @@ -83,6 +91,7 @@ interface Props { getTechnicalInfo?: () => Promise; playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; transcodeReasons?: string[]; + downloadedFiles?: DownloadedItem[]; } const TV_SEEKBAR_HEIGHT = 14; @@ -206,6 +215,7 @@ export const Controls: FC = ({ getTechnicalInfo, playMethod, transcodeReasons, + downloadedFiles, }) => { const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); @@ -391,6 +401,31 @@ export const Controls: FC = ({ seek, }); + // Skip intro/credits hooks + // Note: hooks expect seek callback that takes ms, and seek prop already expects ms + const offline = useOfflineMode(); + const { showSkipButton, skipIntro } = useIntroSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + ); + + const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = + useCreditSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + max.value, + ); + // Countdown logic - needs to be early so toggleControls can reference it const isCountdownActive = useMemo(() => { if (!nextItem) return false; @@ -398,6 +433,13 @@ export const Controls: FC = ({ return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); + // Whether any skip card is visible - used to prevent focus conflicts + const isSkipCardVisible = + (showSkipButton && !isCountdownActive) || + (showSkipCreditButton && + (hasContentAfterCredits || !nextItem) && + !isCountdownActive); + // Brief delay to ignore focus events when countdown first appears const countdownJustActivatedRef = useRef(false); @@ -413,6 +455,41 @@ export const Controls: FC = ({ return () => clearTimeout(timeout); }, [isCountdownActive]); + // Brief delay to ignore focus events when skip card first appears + const skipCardJustActivatedRef = useRef(false); + + useEffect(() => { + if (!isSkipCardVisible) { + skipCardJustActivatedRef.current = false; + return; + } + skipCardJustActivatedRef.current = true; + const timeout = setTimeout(() => { + skipCardJustActivatedRef.current = false; + }, 200); + return () => clearTimeout(timeout); + }, [isSkipCardVisible]); + + // Brief delay to ignore focus events after pressing skip button + const skipJustPressedRef = useRef(false); + + // Wrapper to prevent focus events after skip actions + const handleSkipWithDelay = useCallback((skipFn: () => void) => { + skipJustPressedRef.current = true; + skipFn(); + setTimeout(() => { + skipJustPressedRef.current = false; + }, 500); + }, []); + + const handleSkipIntro = useCallback(() => { + handleSkipWithDelay(skipIntro); + }, [handleSkipWithDelay, skipIntro]); + + const handleSkipCredit = useCallback(() => { + handleSkipWithDelay(skipCredit); + }, [handleSkipWithDelay, skipCredit]); + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; @@ -430,8 +507,12 @@ export const Controls: FC = ({ }; const toggleControls = useCallback(() => { - // Skip if countdown just became active (ignore initial focus event) - if (countdownJustActivatedRef.current) return; + // Skip if countdown or skip card just became active (ignore initial focus event) + const shouldIgnore = + countdownJustActivatedRef.current || + skipCardJustActivatedRef.current || + skipJustPressedRef.current; + if (shouldIgnore) return; setShowControls(!showControls); }, [showControls, setShowControls]); @@ -459,10 +540,6 @@ export const Controls: FC = ({ setSeekBubbleTime({ hours, minutes, seconds }); }, []); - const handleBack = useCallback(() => { - // No longer needed since modals are screen-based - }, []); - // Show minimal seek bar (only progress bar, no buttons) const showMinimalSeek = useCallback(() => { setShowMinimalSeekBar(true); @@ -499,16 +576,6 @@ export const Controls: FC = ({ }, 2500); }, []); - // Reset minimal seek bar timeout (call on each seek action) - const _resetMinimalSeekTimeout = useCallback(() => { - if (minimalSeekBarTimeoutRef.current) { - clearTimeout(minimalSeekBarTimeoutRef.current); - } - minimalSeekBarTimeoutRef.current = setTimeout(() => { - setShowMinimalSeekBar(false); - }, 2500); - }, []); - const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); showOptions({ @@ -875,8 +942,12 @@ export const Controls: FC = ({ // Callback for up/down D-pad - show controls with play button focused const handleVerticalDpad = useCallback(() => { - // Skip if countdown just became active (ignore initial focus event) - if (countdownJustActivatedRef.current) return; + // Skip if countdown or skip card just became active (ignore initial focus event) + const shouldIgnore = + countdownJustActivatedRef.current || + skipCardJustActivatedRef.current || + skipJustPressedRef.current; + if (shouldIgnore) return; setFocusPlayButton(true); setShowControls(true); }, [setShowControls]); @@ -885,7 +956,6 @@ export const Controls: FC = ({ showControls, toggleControls, togglePlay, - onBack: handleBack, isProgressBarFocused, onSeekLeft: handleProgressSeekLeft, onSeekRight: handleProgressSeekRight, @@ -1008,6 +1078,33 @@ export const Controls: FC = ({ /> )} + {/* Skip intro card */} + + + {/* Skip credits card - show when there's content after credits, OR no next episode */} + + {nextItem && ( = ({ refSetter={setProgressBarRef} hasTVPreferredFocus={ !isCountdownActive && + !isSkipCardVisible && lastOpenedModal === null && !focusPlayButton } diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index c813d1419..f30fda23a 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -101,9 +101,7 @@ export function useRemoteControl({ // Handle play/pause button press on TV remote if (evt.eventType === "playPause") { - if (togglePlay) { - togglePlay(); - } + togglePlay?.(); onInteraction?.(); return; } @@ -134,6 +132,11 @@ export function useRemoteControl({ // Handle D-pad when controls are hidden if (!showControls) { + // Ignore select/enter events - let the native Pressable handle them + // This prevents controls from showing when pressing buttons like skip intro + if (evt.eventType === "select" || evt.eventType === "enter") { + return; + } // Minimal seek mode for left/right if (evt.eventType === "left" && onMinimalSeekLeft) { onMinimalSeekLeft(); diff --git a/translations/en.json b/translations/en.json index 18a6fbbd9..78edbee05 100644 --- a/translations/en.json +++ b/translations/en.json @@ -671,7 +671,9 @@ "no_subtitle_provider": "No subtitle provider configured on server", "no_subtitles_found": "No subtitles found", "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback", - "settings": "Settings" + "settings": "Settings", + "skip_intro": "Skip Intro", + "skip_credits": "Skip Credits" }, "item_card": { "next_up": "Next Up", @@ -722,7 +724,11 @@ "download_button": "Download" }, "mark_played": "Mark as Watched", - "mark_unplayed": "Mark as Unwatched" + "mark_unplayed": "Mark as Unwatched", + "resume_playback": "Resume Playback", + "resume_playback_description": "Do you want to continue where you left off or start from the beginning?", + "play_from_start": "Play from Start", + "continue_from": "Continue from {{time}}" }, "live_tv": { "next": "Next", From b87e7a159f7fb5297c3cb211a504065882feb933 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 19:09:31 +0100 Subject: [PATCH 152/309] fix(tv): home screen sections not loading --- components/home/Home.tv.tsx | 37 +------------ .../InfiniteScrollingCollectionList.tv.tsx | 54 +++++++------------ 2 files changed, 20 insertions(+), 71 deletions(-) diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 770597c21..6ce36e82c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -53,7 +53,6 @@ type InfiniteScrollingCollectionListSection = { queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; pageSize?: number; - priority?: 1 | 2; parentId?: string; }; @@ -79,7 +78,6 @@ export const Home = () => { } = useNetworkStatus(); const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); - const [loadedSections, setLoadedSections] = useState>(new Set()); // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); @@ -383,7 +381,6 @@ export const Home = () => { type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, - priority: 1, }, ] : [ @@ -403,7 +400,6 @@ export const Home = () => { type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, - priority: 1, }, { title: t("home.next_up"), @@ -421,13 +417,12 @@ export const Home = () => { type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, - priority: 1, }, ]; const ss: Section[] = [ ...firstSections, - ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })), + ...latestMediaViews, ...(!settings?.streamyStatsMovieRecommendations ? [ { @@ -446,7 +441,6 @@ export const Home = () => { type: "InfiniteScrollingCollectionList" as const, orientation: "vertical" as const, pageSize: 10, - priority: 2 as const, }, ] : []), @@ -531,7 +525,6 @@ export const Home = () => { type: "InfiniteScrollingCollectionList", orientation: section?.orientation || "vertical", pageSize, - priority: index < 2 ? 1 : 2, }); }); return ss; @@ -550,24 +543,6 @@ export const Home = () => { return showHero ? sections.slice(1) : sections; }, [sections, showHero]); - const highPrioritySectionKeys = useMemo(() => { - return renderedSections - .filter((s) => s.priority === 1) - .map((s) => s.queryKey.join("-")); - }, [renderedSections]); - - const allHighPriorityLoaded = useMemo(() => { - return highPrioritySectionKeys.every((key) => loadedSections.has(key)); - }, [highPrioritySectionKeys, loadedSections]); - - const markSectionLoaded = useCallback( - (queryKey: (string | undefined | null)[]) => { - const key = queryKey.join("-"); - setLoadedSections((prev) => new Set(prev).add(key)); - }, - [], - ); - if (!isConnected || serverConnected !== true) { let title = ""; let subtitle = ""; @@ -785,7 +760,6 @@ export const Home = () => { "home.settings.plugins.streamystats.recommended_movies", )} type='Movie' - enabled={allHighPriorityLoaded} onItemFocus={handleItemFocus} /> )} @@ -795,13 +769,11 @@ export const Home = () => { "home.settings.plugins.streamystats.recommended_series", )} type='Series' - enabled={allHighPriorityLoaded} onItemFocus={handleItemFocus} /> )} {settings.streamyStatsPromotedWatchlists && ( )} @@ -809,7 +781,6 @@ export const Home = () => { ) : null; if (section.type === "InfiniteScrollingCollectionList") { - const isHighPriority = section.priority === 1; // First section only gets preferred focus if hero is not shown const isFirstSection = index === 0 && !showHero; return ( @@ -821,12 +792,6 @@ export const Home = () => { orientation={section.orientation} hideIfEmpty pageSize={section.pageSize} - enabled={isHighPriority || allHighPriorityLoaded} - onLoaded={ - isHighPriority - ? () => markSectionLoaded(section.queryKey) - : undefined - } isFirstSection={isFirstSection} onItemFocus={handleItemFocus} parentId={section.parentId} diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index b4bfb73a5..0b4e194ba 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -6,7 +6,7 @@ import { useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -38,7 +38,6 @@ interface Props extends ViewProps { pageSize?: number; onPressSeeAll?: () => void; enabled?: boolean; - onLoaded?: () => void; isFirstSection?: boolean; onItemFocus?: (item: BaseItemDto) => void; parentId?: string; @@ -120,7 +119,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ hideIfEmpty = false, pageSize = 10, enabled = true, - onLoaded, isFirstSection = false, onItemFocus, parentId, @@ -131,7 +129,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const sizes = useScaledTVSizes(); const ITEM_GAP = sizes.gaps.item; const effectivePageSize = Math.max(1, pageSize); - const hasCalledOnLoaded = useRef(false); const router = useRouter(); const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); @@ -158,37 +155,24 @@ export const InfiniteScrollingCollectionList: React.FC = ({ setFocusedCount((c) => c + 1); }, []); - const { - data, - isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - isSuccess, - } = useInfiniteQuery({ - queryKey: queryKey, - queryFn: ({ pageParam = 0, ...context }) => - queryFn({ ...context, queryKey, pageParam }), - getNextPageParam: (lastPage, allPages) => { - if (lastPage.length < effectivePageSize) { - return undefined; - } - return allPages.reduce((acc, page) => acc + page.length, 0); - }, - initialPageParam: 0, - staleTime: 60 * 1000, - refetchInterval: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: true, - enabled, - }); - - useEffect(() => { - if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { - hasCalledOnLoaded.current = true; - onLoaded(); - } - }, [isSuccess, onLoaded]); + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: queryKey, + queryFn: ({ pageParam = 0, ...context }) => + queryFn({ ...context, queryKey, pageParam }), + getNextPageParam: (lastPage, allPages) => { + if (lastPage.length < effectivePageSize) { + return undefined; + } + return allPages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + enabled, + }); const { t } = useTranslation(); From 2818c17e9713e6fe329fb05d097439c565c67998 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 19:16:53 +0100 Subject: [PATCH 153/309] chore: sv translations --- translations/sv.json | 75 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/translations/sv.json b/translations/sv.json index a9d52daf0..975549315 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -42,7 +42,9 @@ "accounts_count": "{{count}} konton", "select_account": "Välj konto", "add_account": "Lägg till konto", - "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}." + "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}.", + "remove_server": "Ta bort server", + "remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista." }, "save_account": { "title": "Spara konto", @@ -130,7 +132,8 @@ "display_size_small": "Liten", "display_size_default": "Standard", "display_size_large": "Stor", - "display_size_extra_large": "Extra stor" + "display_size_extra_large": "Extra stor", + "theme_music": "Temamusik" }, "network": { "title": "Nätverk", @@ -183,6 +186,16 @@ "rewind_length": "Bakåthoppsintervall", "seconds_unit": "s" }, + "buffer": { + "title": "Bufferinställningar", + "cache_mode": "Cacheläge", + "cache_auto": "Auto", + "cache_yes": "Aktiverad", + "cache_no": "Inaktiverad", + "buffer_duration": "Buffertlängd", + "max_cache_size": "Max cachestorlek", + "max_backward_cache": "Max bakåtcache" + }, "gesture_controls": { "gesture_controls_title": "Gestkontroller", "horizontal_swipe_skip": "Horisontell Svepning för att Hoppa Fram/Bak", @@ -265,7 +278,23 @@ "subtitle_font": "Typsnitt för undertexter", "ksplayer_title": "KSPlayer-inställningar", "hardware_decode": "Hårdvaruavkodning", - "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem." + "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem.", + "opensubtitles_title": "OpenSubtitles", + "opensubtitles_hint": "Ange din OpenSubtitles API-nyckel för att aktivera klientbaserad undertextsökning som reserv när din Jellyfin-server inte har en undertextleverantör konfigurerad.", + "opensubtitles_api_key": "API-nyckel", + "opensubtitles_api_key_placeholder": "Ange API-nyckel...", + "opensubtitles_get_key": "Skaffa din gratis API-nyckel på opensubtitles.com/en/consumers", + "mpv_subtitle_scale": "Undertextskala", + "mpv_subtitle_margin_y": "Vertikal marginal", + "mpv_subtitle_align_x": "Horisontell justering", + "mpv_subtitle_align_y": "Vertikal justering", + "align": { + "left": "Vänster", + "center": "Mitten", + "right": "Höger", + "top": "Toppen", + "bottom": "Botten" + } }, "vlc_subtitles": { "title": "VLC undertextsinställningar", @@ -501,6 +530,7 @@ } }, "common": { + "no_results": "Inga resultat", "select": "Välj", "no_trailer_available": "Ingen trailer tillgänglig", "video": "Video", @@ -568,6 +598,7 @@ "movies": "Filmer", "series": "Serier", "boxsets": "Box Set", + "playlists": "Spellistor", "items": "Artiklar" }, "options": { @@ -607,6 +638,7 @@ "no_links": "Inga Länkar" }, "player": { + "live": "LIVE", "error": "Fel", "failed_to_get_stream_url": "Kunde inte hämta stream-URL", "an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.", @@ -625,7 +657,14 @@ "downloaded_file_yes": "Ja", "downloaded_file_no": "Nej", "downloaded_file_cancel": "Avbryt", + "swipe_down_settings": "Svep nedåt för inställningar", + "ends_at": "slutar", + "search_subtitles": "Sök undertexter", + "subtitle_tracks": "Spår", + "subtitle_search": "Sök & ladda ner", "download": "Ladda ner", + "subtitle_download_hint": "Nedladdade undertexter sparas i ditt bibliotek", + "using_jellyfin_server": "Använder Jellyfin-server", "language": "Språk", "results": "Resultat", "searching": "Söker...", @@ -633,8 +672,9 @@ "no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern", "no_subtitles_found": "Inga undertexter hittades", "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv", - "ends_at": "slutar", - "settings": "Inställningar" + "settings": "Inställningar", + "skip_intro": "Hoppa över intro", + "skip_credits": "Hoppa över eftertexter" }, "item_card": { "next_up": "Näst på tur", @@ -683,7 +723,13 @@ "download_x_item": "Ladda Ner {{item_count}} Objekt", "download_unwatched_only": "Endast Osedda", "download_button": "Ladda ner" - } + }, + "mark_played": "Markera som sedd", + "mark_unplayed": "Markera som osedd", + "resume_playback": "Återuppta uppspelning", + "resume_playback_description": "Vill du fortsätta där du slutade eller börja om från början?", + "play_from_start": "Spela från början", + "continue_from": "Fortsätt från {{time}}" }, "live_tv": { "next": "Nästa", @@ -694,7 +740,18 @@ "movies": "Filmer", "sports": "Sport", "for_kids": "För barn", - "news": "Nyheter" + "news": "Nyheter", + "page_of": "Sida {{current}} av {{total}}", + "no_programs": "Inga program tillgängliga", + "no_channels": "Inga kanaler tillgängliga", + "tabs": { + "programs": "Program", + "guide": "Guide", + "channels": "Kanaler", + "recordings": "Inspelningar", + "schedule": "Schema", + "series": "Serier" + } }, "jellyseerr": { "confirm": "Bekräfta", @@ -741,6 +798,10 @@ "unknown_user": "Okänd användare", "select": "Välj", "request_all": "Begär alla", + "request_seasons": "Begär säsonger", + "select_seasons": "Välj säsonger", + "request_selected": "Begär valda", + "n_selected": "{{count}} valda", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0", "jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.", From d78ac2963fe9e03e8417af97b62c5587765a8154 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 19:38:25 +0100 Subject: [PATCH 154/309] feat(tv): add language selector --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 3c92b8af7..3f7adc14f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -17,6 +17,7 @@ import { } from "@/components/tv"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { APP_LANGUAGES } from "@/i18n"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, @@ -49,6 +50,7 @@ export default function SettingsTV() { const currentTypographyScale = settings.tvTypographyScale || TVTypographyScale.Default; const currentCacheMode = settings.mpvCacheEnabled ?? "auto"; + const currentLanguage = settings.preferedLanguage; // Audio transcoding options const audioTranscodeModeOptions: TVOptionItem[] = useMemo( @@ -189,6 +191,23 @@ export default function SettingsTV() { [t, currentTypographyScale], ); + // Language options + const languageOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.languages.system"), + value: undefined, + selected: !currentLanguage, + }, + ...APP_LANGUAGES.map((lang) => ({ + label: lang.label, + value: lang.value, + selected: currentLanguage === lang.value, + })), + ], + [t, currentLanguage], + ); + // Get display labels for option buttons const audioTranscodeLabel = useMemo(() => { const option = audioTranscodeModeOptions.find((o) => o.selected); @@ -220,6 +239,12 @@ export default function SettingsTV() { return option?.label || t("home.settings.buffer.cache_auto"); }, [cacheModeOptions, t]); + const languageLabel = useMemo(() => { + if (!currentLanguage) return t("home.settings.languages.system"); + const option = APP_LANGUAGES.find((l) => l.value === currentLanguage); + return option?.label || t("home.settings.languages.system"); + }, [currentLanguage, t]); + return ( @@ -480,6 +505,18 @@ export default function SettingsTV() { }) } /> + + showOptions({ + title: t("home.settings.languages.app_language"), + options: languageOptions, + onSelect: (value) => + updateSettings({ preferedLanguage: value }), + }) + } + /> Date: Fri, 30 Jan 2026 20:45:00 +0100 Subject: [PATCH 155/309] fix(tv): poster images --- components/home/Home.tv.tsx | 11 ++++++++--- components/tv/TVPosterCard.tsx | 12 ++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 6ce36e82c..edea23d38 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -250,7 +250,7 @@ export const Home = () => { deduped.push(item); } - return deduped.slice(0, 8); + return deduped.slice(0, 15); }, enabled: !!api && !!user?.Id, staleTime: 60 * 1000, @@ -539,9 +539,14 @@ export const Home = () => { }, [heroItems, settings.showTVHeroCarousel]); // Get sections that will actually be rendered (accounting for hero slicing) + // When hero is shown, skip the first sections since hero already displays that content + // - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up) + // - Otherwise: skip 2 sections (separate Continue Watching + Next Up) const renderedSections = useMemo(() => { - return showHero ? sections.slice(1) : sections; - }, [sections, showHero]); + if (!showHero) return sections; + const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2; + return sections.slice(sectionsToSkip); + }, [sections, showHero, settings.mergeNextUpAndContinueWatching]); if (!isConnected || serverConnected !== true) { let title = ""; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index d27bc4f66..c82598381 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -133,16 +133,16 @@ export const TVPosterCard: React.FC = ({ // Horizontal orientation: prefer thumbs/backdrops for landscape images if (orientation === "horizontal") { - // Episode: prefer episode's own primary image, fall back to parent thumb + // Episode: prefer series thumb image for consistent look (like hero section) if (item.Type === "Episode") { - // First try episode's own primary image - if (item.ImageTags?.Primary) { - return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`; - } - // Fall back to parent thumb if episode has no image + // First try parent/series thumb (horizontal series artwork) if (item.ParentBackdropItemId && item.ParentThumbImageTag) { return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; } + // Fall back to episode's own primary image + if (item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`; + } // Last resort: try primary without tag return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } From 6e85c8d54ac6a3c72a74917157c542593cb6c611 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 09:53:54 +0100 Subject: [PATCH 156/309] feat(tv): add user switching from settings --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 136 +++++++++++++- app/(auth)/tv-user-switch-modal.tsx | 174 ++++++++++++++++++ app/_layout.tsx | 8 + components/tv/TVUserCard.tsx | 174 ++++++++++++++++++ components/tv/index.ts | 3 + .../tv/settings/TVSettingsOptionButton.tsx | 1 + hooks/useTVUserSwitchModal.ts | 42 +++++ providers/JellyfinProvider.tsx | 8 + translations/en.json | 6 + utils/atoms/tvUserSwitchModal.ts | 12 ++ 10 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/tv-user-switch-modal.tsx create mode 100644 components/tv/TVUserCard.tsx create mode 100644 hooks/useTVUserSwitchModal.ts create mode 100644 utils/atoms/tvUserSwitchModal.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 3f7adc14f..9e5b794d3 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; +import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; +import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; import type { TVOptionItem } from "@/components/tv"; import { TVLogoutButton, @@ -17,6 +19,7 @@ import { } from "@/components/tv"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { APP_LANGUAGES } from "@/i18n"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { @@ -25,15 +28,21 @@ import { TVTypographyScale, useSettings, } from "@/utils/atoms/settings"; +import { + getPreviousServers, + type SavedServer, + type SavedServerAccount, +} from "@/utils/secureCredentials"; export default function SettingsTV() { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const { settings, updateSettings } = useSettings(); - const { logout } = useJellyfin(); + const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); const { showOptions } = useTVOptionModal(); + const { showUserSwitchModal } = useTVUserSwitchModal(); const typography = useScaledTVTypography(); // Local state for OpenSubtitles API key (only commit on blur) @@ -41,6 +50,89 @@ export default function SettingsTV() { settings.openSubtitlesApiKey || "", ); + // PIN/Password modal state for user switching + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [selectedAccount, setSelectedAccount] = + useState(null); + + // Track if any modal is open to disable background focus + const isAnyModalOpen = pinModalVisible || passwordModalVisible; + + // Get current server and other accounts + const currentServer = useMemo(() => { + if (!api?.basePath) return null; + const servers = getPreviousServers(); + return servers.find((s) => s.address === api.basePath) || null; + }, [api?.basePath]); + + const otherAccounts = useMemo(() => { + if (!currentServer || !user?.Id) return []; + return currentServer.accounts.filter( + (account) => account.userId !== user.Id, + ); + }, [currentServer, user?.Id]); + + const hasOtherAccounts = otherAccounts.length > 0; + + // Handle account selection from modal + const handleAccountSelect = (account: SavedServerAccount) => { + if (!currentServer) return; + + if (account.securityType === "none") { + // Direct login with saved credential + loginWithSavedCredential(currentServer.address, account.userId); + } else if (account.securityType === "pin") { + // Show PIN modal + setSelectedServer(currentServer); + setSelectedAccount(account); + setPinModalVisible(true); + } else if (account.securityType === "password") { + // Show password modal + setSelectedServer(currentServer); + setSelectedAccount(account); + setPasswordModalVisible(true); + } + }; + + // Handle successful PIN entry + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (selectedServer && selectedAccount) { + await loginWithSavedCredential( + selectedServer.address, + selectedAccount.userId, + ); + } + setSelectedServer(null); + setSelectedAccount(null); + }; + + // Handle password submission + const handlePasswordSubmit = async (password: string) => { + if (selectedServer && selectedAccount) { + await loginWithPassword( + selectedServer.address, + selectedAccount.username, + password, + ); + } + setPasswordModalVisible(false); + setSelectedServer(null); + setSelectedAccount(null); + }; + + // Handle switch user button press + const handleSwitchUser = () => { + if (!currentServer || !user?.Id) return; + showUserSwitchModal(currentServer, user.Id, { + onAccountSelect: handleAccountSelect, + }); + }; + const currentAudioTranscode = settings.audioTranscodeMode || AudioTranscodeMode.Auto; const currentSubtitleMode = @@ -269,6 +361,16 @@ export default function SettingsTV() { {t("home.settings.settings_title")} + {/* Account Section */} + + + {/* Audio Section */} {/* Subtitles Section */} @@ -570,6 +671,37 @@ export default function SettingsTV() { + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={() => { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + serverUrl={selectedServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> ); } diff --git a/app/(auth)/tv-user-switch-modal.tsx b/app/(auth)/tv-user-switch-modal.tsx new file mode 100644 index 000000000..1478b0f7b --- /dev/null +++ b/app/(auth)/tv-user-switch-modal.tsx @@ -0,0 +1,174 @@ +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVUserCard } from "@/components/tv/TVUserCard"; +import useRouter from "@/hooks/useAppRouter"; +import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +export default function TVUserSwitchModalPage() { + const { t } = useTranslation(); + const router = useRouter(); + const modalState = useAtomValue(tvUserSwitchModalAtom); + + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Animate in on mount and cleanup atom on unmount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + // Delay focus setup to allow layout + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + // Clear the atom on unmount to prevent stale callbacks from being retained + store.set(tvUserSwitchModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + // Request focus on the first card when ready + useEffect(() => { + if (isReady && firstCardRef.current) { + const timer = setTimeout(() => { + (firstCardRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady]); + + const handleSelect = (account: SavedServerAccount) => { + modalState?.onAccountSelect(account); + store.set(tvUserSwitchModalAtom, null); + router.back(); + }; + + // If no modal state, just return null + if (!modalState) { + return null; + } + + return ( + + + + + + {t("home.settings.switch_user.title")} + + {modalState.serverName} + {isReady && ( + + {modalState.accounts.map((account, index) => { + const isCurrent = account.userId === modalState.currentUserId; + return ( + handleSelect(account)} + /> + ); + })} + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: 18, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 4, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + subtitle: { + fontSize: 14, + color: "rgba(255,255,255,0.4)", + marginBottom: 16, + paddingHorizontal: 48, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 20, + gap: 16, + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index a78471804..a3dc6240b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -488,6 +488,14 @@ function Layout() { animation: "fade", }} /> + void; +} + +export const TVUserCard = React.forwardRef( + ( + { + username, + securityType, + hasTVPreferredFocus = false, + isCurrent = false, + onPress, + }, + ref, + ) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: isCurrent ? 1.02 : 1.05 }); + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const getSecurityText = (): string => { + switch (securityType) { + case "pin": + return t("save_account.pin_code"); + case "password": + return t("save_account.password"); + default: + return t("save_account.no_protection"); + } + }; + + const getBackgroundColor = () => { + if (isCurrent) { + return focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.04)"; + } + return focused ? "#fff" : "rgba(255,255,255,0.08)"; + }; + + const getTextColor = () => { + if (isCurrent) { + return "rgba(255,255,255,0.4)"; + } + return focused ? "#000" : "#fff"; + }; + + const getSecondaryColor = () => { + if (isCurrent) { + return "rgba(255,255,255,0.25)"; + } + return focused ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.5)"; + }; + + return ( + + + {/* User Avatar */} + + + + + {/* Text column */} + + {/* Username */} + + + {username} + + {isCurrent && ( + + ({t("home.settings.switch_user.current")}) + + )} + + + {/* Security indicator */} + + + + {getSecurityText()} + + + + + + ); + }, +); diff --git a/components/tv/index.ts b/components/tv/index.ts index 76a6e60e9..a35104eb0 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -65,3 +65,6 @@ export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator"; // Subtitle sheet components export type { TVTrackCardProps } from "./TVTrackCard"; export { TVTrackCard } from "./TVTrackCard"; +// User switching +export type { TVUserCardProps } from "./TVUserCard"; +export { TVUserCard } from "./TVUserCard"; diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx index 07f879ce9..0166f99be 100644 --- a/components/tv/settings/TVSettingsOptionButton.tsx +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -47,6 +47,7 @@ export const TVSettingsOptionButton: React.FC = ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", + opacity: disabled ? 0.4 : 1, }, ]} > diff --git a/hooks/useTVUserSwitchModal.ts b/hooks/useTVUserSwitchModal.ts new file mode 100644 index 000000000..a0b0a9441 --- /dev/null +++ b/hooks/useTVUserSwitchModal.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface UseTVUserSwitchModalOptions { + onAccountSelect: (account: SavedServerAccount) => void; +} + +export function useTVUserSwitchModal() { + const router = useRouter(); + + const showUserSwitchModal = useCallback( + ( + server: SavedServer, + currentUserId: string, + options: UseTVUserSwitchModalOptions, + ) => { + // Need at least 2 accounts (current + at least one other) + if (server.accounts.length < 2) { + return; + } + + store.set(tvUserSwitchModalAtom, { + serverUrl: server.address, + serverName: server.name || server.address, + accounts: server.accounts, + currentUserId, + onAccountSelect: options.onAccountSelect, + }); + + router.push("/(auth)/tv-user-switch-modal"); + }, + [router], + ); + + return { showUserSwitchModal }; +} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 4ad040de7..97ba07e0a 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -389,6 +389,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ try { const response = await getUserApi(apiInstance).getCurrentUser(); + // Clear React Query cache to prevent data from previous account lingering + queryClient.clear(); + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + // Token is valid, update state setApi(apiInstance); setUser(response.data); @@ -437,6 +441,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const auth = await apiInstance.authenticateUserByName(username, password); if (auth.data.AccessToken && auth.data.User) { + // Clear React Query cache to prevent data from previous account lingering + queryClient.clear(); + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + setUser(auth.data.User); storage.set("user", JSON.stringify(auth.data.User)); setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken)); diff --git a/translations/en.json b/translations/en.json index 78edbee05..651a1aa39 100644 --- a/translations/en.json +++ b/translations/en.json @@ -112,6 +112,12 @@ "settings": { "settings_title": "Settings", "log_out_button": "Log Out", + "switch_user": { + "title": "Switch User", + "account": "Account", + "switch_user": "Switch User", + "current": "current" + }, "categories": { "title": "Categories" }, diff --git a/utils/atoms/tvUserSwitchModal.ts b/utils/atoms/tvUserSwitchModal.ts new file mode 100644 index 000000000..2df72df10 --- /dev/null +++ b/utils/atoms/tvUserSwitchModal.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; + +export type TVUserSwitchModalState = { + serverUrl: string; + serverName: string; + accounts: SavedServerAccount[]; + currentUserId: string; + onAccountSelect: (account: SavedServerAccount) => void; +} | null; + +export const tvUserSwitchModalAtom = atom(null); From 85a74a9a6af4fee3d892146ed5955ad54930e02e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 10:52:21 +0100 Subject: [PATCH 157/309] refactor: login page --- CLAUDE.md | 95 +- app/_layout.tsx | 2 +- ...-modal.tsx => tv-account-action-modal.tsx} | 53 +- app/tv-account-select-modal.tsx | 217 +++-- components/PasswordEntryModal.tsx | 6 +- components/login/TVAddIcon.tsx | 82 ++ components/login/TVAddServerForm.tsx | 162 ++++ components/login/TVAddUserForm.tsx | 230 +++++ components/login/TVBackIcon.tsx | 82 ++ components/login/TVLogin.tsx | 856 +++++++----------- components/login/TVPINEntryModal.tsx | 380 +++++--- components/login/TVPasswordEntryModal.tsx | 6 +- components/login/TVPreviousServersList.tsx | 237 ----- components/login/TVServerCard.tsx | 152 ---- components/login/TVServerIcon.tsx | 118 +++ components/login/TVServerSelectionScreen.tsx | 137 +++ components/login/TVUserIcon.tsx | 127 +++ components/login/TVUserSelectionScreen.tsx | 130 +++ docs/tv-modal-guide.md | 416 +++++++++ hooks/useTVAccountActionModal.ts | 34 + hooks/useTVAccountSelectModal.ts | 8 +- hooks/useTVServerActionModal.ts | 29 - translations/en.json | 11 +- utils/atoms/selectedTVServer.ts | 60 ++ utils/atoms/tvAccountActionModal.ts | 14 + utils/atoms/tvAccountSelectModal.ts | 4 +- utils/atoms/tvServerActionModal.ts | 10 - 27 files changed, 2422 insertions(+), 1236 deletions(-) rename app/{tv-server-action-modal.tsx => tv-account-action-modal.tsx} (86%) create mode 100644 components/login/TVAddIcon.tsx create mode 100644 components/login/TVAddServerForm.tsx create mode 100644 components/login/TVAddUserForm.tsx create mode 100644 components/login/TVBackIcon.tsx delete mode 100644 components/login/TVPreviousServersList.tsx delete mode 100644 components/login/TVServerCard.tsx create mode 100644 components/login/TVServerIcon.tsx create mode 100644 components/login/TVServerSelectionScreen.tsx create mode 100644 components/login/TVUserIcon.tsx create mode 100644 components/login/TVUserSelectionScreen.tsx create mode 100644 docs/tv-modal-guide.md create mode 100644 hooks/useTVAccountActionModal.ts delete mode 100644 hooks/useTVServerActionModal.ts create mode 100644 utils/atoms/selectedTVServer.ts create mode 100644 utils/atoms/tvAccountActionModal.ts delete mode 100644 utils/atoms/tvServerActionModal.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0c037d420..357616b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. - **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. - **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. -- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference. +- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.** ### TV Component Rendering Pattern @@ -196,98 +196,9 @@ export default LoginPage; - TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling - **Never use `.tv.tsx` file suffix** - it will not be resolved correctly -### TV Option Selector Pattern (Dropdowns/Multi-select) +### TV Option Selectors and Focus Management -For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because: -- Horizontal scrolling is natural for TV remotes (left/right D-pad) -- Bottom sheet takes minimal screen space -- Focus-based navigation works reliably - -**Key implementation details:** - -1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead: -```typescript - - - {/* Content */} - - -``` - -2. **Horizontal ScrollView with focusable cards**: -```typescript - - {options.map((option, index) => ( - { onSelect(option.value); onClose(); }} - // ... - /> - ))} - -``` - -3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`: -```typescript - { setFocused(true); animateTo(1.05); }} - onBlur={() => { setFocused(false); animateTo(1); }} - hasTVPreferredFocus={hasTVPreferredFocus} -> - - {label} - - -``` - -4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip. - -**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx` - -### TV Focus Management for Overlays/Modals - -**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation. - -**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: - -```typescript -// 1. Track modal state -const [openModal, setOpenModal] = useState(null); -const isModalOpen = openModal !== null; - -// 2. Each focusable component accepts disabled prop -const TVFocusableButton: React.FC<{ - onPress: () => void; - disabled?: boolean; -}> = ({ onPress, disabled }) => ( - - {/* content */} - -); - -// 3. Pass disabled to all background components when modal is open - -``` - -**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. +For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md). ### TV Focus Flickering Between Zones (Lists with Headers) diff --git a/app/_layout.tsx b/app/_layout.tsx index a3dc6240b..43fe21867 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -473,7 +473,7 @@ function Layout() { }} /> @@ -105,10 +104,10 @@ const TVServerActionCard: React.FC<{ ); }; -export default function TVServerActionModalPage() { +export default function TVAccountActionModalPage() { const typography = useScaledTVTypography(); const router = useRouter(); - const modalState = useAtomValue(tvServerActionModalAtom); + const modalState = useAtomValue(tvAccountActionModalAtom); const { t } = useTranslation(); const [isReady, setIsReady] = useState(false); @@ -138,7 +137,7 @@ export default function TVServerActionModalPage() { const timer = setTimeout(() => setIsReady(true), 100); return () => { clearTimeout(timer); - store.set(tvServerActionModalAtom, null); + store.set(tvAccountActionModalAtom, null); }; }, [overlayOpacity, sheetTranslateY]); @@ -152,10 +151,6 @@ export default function TVServerActionModalPage() { router.back(); }; - const handleClose = () => { - router.back(); - }; - if (!modalState) { return null; } @@ -196,16 +191,27 @@ export default function TVServerActionModalPage() { overflow: "visible", }} > - {/* Title */} + {/* Account username as title */} + + {modalState.account.username} + + + {/* Server name as subtitle */} {modalState.server.name || modalState.server.address} @@ -223,23 +229,18 @@ export default function TVServerActionModalPage() { gap: 12, }} > - - - )} diff --git a/app/tv-account-select-modal.tsx b/app/tv-account-select-modal.tsx index ec8be5a5e..a8a8e6bd8 100644 --- a/app/tv-account-select-modal.tsx +++ b/app/tv-account-select-modal.tsx @@ -1,16 +1,108 @@ +import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import { useAtomValue } from "jotai"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Animated, Easing, TVFocusGuideView, View } from "react-native"; -import { Button } from "@/components/Button"; +import { + Animated, + Easing, + Pressable, + ScrollView, + TVFocusGuideView, +} from "react-native"; import { Text } from "@/components/common/Text"; -import { TVAccountCard } from "@/components/login/TVAccountCard"; +import { TVUserCard } from "@/components/tv/TVUserCard"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal"; import { store } from "@/utils/store"; +// Action button for bottom sheet +const TVAccountSelectAction: React.FC<{ + label: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: "default" | "destructive"; + onPress: () => void; +}> = ({ label, icon, variant = "default", onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const typography = useScaledTVTypography(); + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isDestructive = variant === "destructive"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + + {label} + + + + ); +}; + export default function TVAccountSelectModalPage() { const typography = useScaledTVTypography(); const router = useRouter(); @@ -19,12 +111,12 @@ export default function TVAccountSelectModalPage() { const [isReady, setIsReady] = useState(false); const overlayOpacity = useRef(new Animated.Value(0)).current; - const contentScale = useRef(new Animated.Value(0.9)).current; + const sheetTranslateY = useRef(new Animated.Value(300)).current; // Animate in on mount useEffect(() => { overlayOpacity.setValue(0); - contentScale.setValue(0.9); + sheetTranslateY.setValue(300); Animated.parallel([ Animated.timing(overlayOpacity, { @@ -33,8 +125,8 @@ export default function TVAccountSelectModalPage() { easing: Easing.out(Easing.quad), useNativeDriver: true, }), - Animated.timing(contentScale, { - toValue: 1, + Animated.timing(sheetTranslateY, { + toValue: 0, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true, @@ -46,11 +138,7 @@ export default function TVAccountSelectModalPage() { clearTimeout(timer); store.set(tvAccountSelectModalAtom, null); }; - }, [overlayOpacity, contentScale]); - - const handleClose = () => { - router.back(); - }; + }, [overlayOpacity, sheetTranslateY]); if (!modalState) { return null; @@ -60,25 +148,23 @@ export default function TVAccountSelectModalPage() { @@ -89,67 +175,78 @@ export default function TVAccountSelectModalPage() { trapFocusLeft trapFocusRight style={{ - padding: 40, + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", }} > + {/* Title */} {t("server.select_account")} + + {/* Server name as subtitle */} {modalState.server.name || modalState.server.address} + {/* All options in single horizontal row */} {isReady && ( - <> - - {modalState.server.accounts?.map((account, index) => ( - { - modalState.onAccountSelect(account); - router.back(); - }} - onLongPress={() => { - modalState.onDeleteAccount(account); - }} - hasTVPreferredFocus={index === 0} - /> - ))} - - - - - - - + hasTVPreferredFocus={index === 0} + /> + ))} + { + modalState.onAddAccount(); + router.back(); + }} + /> + { + modalState.onDeleteServer(); + router.back(); + }} + /> + )} diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx index 63b4efe6a..efd1cc49d 100644 --- a/components/PasswordEntryModal.tsx +++ b/components/PasswordEntryModal.tsx @@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC = ({ {/* Password Input */} - {t("login.password")} + {t("login.password_placeholder")} = ({ setPassword(text); setError(null); }} - placeholder={t("login.password")} + placeholder={t("login.password_placeholder")} placeholderTextColor='#6B7280' secureTextEntry autoFocus @@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC = ({ {isLoading ? ( ) : ( - t("login.login") + t("common.login") )} diff --git a/components/login/TVAddIcon.tsx b/components/login/TVAddIcon.tsx new file mode 100644 index 000000000..111c706a3 --- /dev/null +++ b/components/login/TVAddIcon.tsx @@ -0,0 +1,82 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVAddIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVAddIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx new file mode 100644 index 000000000..7d3cefe17 --- /dev/null +++ b/components/login/TVAddServerForm.tsx @@ -0,0 +1,162 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TVInput } from "./TVInput"; + +interface TVAddServerFormProps { + onConnect: (url: string) => Promise; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; +}> = ({ onPress, label, disabled = false }) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 24 }} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + ); +}; + +export const TVAddServerForm: React.FC = ({ + onConnect, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [serverURL, setServerURL] = useState(""); + + const handleConnect = async () => { + if (serverURL.trim()) { + await onConnect(serverURL.trim()); + } + }; + + const isDisabled = disabled || loading; + + return ( + + + {/* Back Button */} + + + {/* Server URL Input */} + + + + + {/* Connect Button */} + + + + + {/* Hint text */} + + {t("server.enter_url_to_jellyfin_server")} + + + + ); +}; diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx new file mode 100644 index 000000000..17f8fe89a --- /dev/null +++ b/components/login/TVAddUserForm.tsx @@ -0,0 +1,230 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TVInput } from "./TVInput"; +import { TVSaveAccountToggle } from "./TVSaveAccountToggle"; + +interface TVAddUserFormProps { + serverName: string; + serverAddress: string; + onLogin: ( + username: string, + password: string, + saveAccount: boolean, + ) => Promise; + onQuickConnect: () => Promise; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; +}> = ({ onPress, label, disabled = false }) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 40 }} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + ); +}; + +export const TVAddUserForm: React.FC = ({ + serverName, + serverAddress, + onLogin, + onQuickConnect, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [credentials, setCredentials] = useState({ + username: "", + password: "", + }); + const [saveAccount, setSaveAccount] = useState(false); + + const handleLogin = async () => { + if (credentials.username.trim()) { + await onLogin(credentials.username, credentials.password, saveAccount); + } + }; + + const isDisabled = disabled || loading; + + return ( + + + {/* Back Button */} + + + {/* Title */} + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + + {serverAddress} + + + {/* Username Input */} + + + setCredentials((prev) => ({ ...prev, username: text })) + } + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + returnKeyType='next' + hasTVPreferredFocus + disabled={isDisabled} + /> + + + {/* Password Input */} + + + setCredentials((prev) => ({ ...prev, password: text })) + } + secureTextEntry + autoCapitalize='none' + textContentType='password' + returnKeyType='done' + disabled={isDisabled} + /> + + + {/* Save Account Toggle */} + + + + + {/* Login Button */} + + + + + {/* Quick Connect Button */} + + + + ); +}; diff --git a/components/login/TVBackIcon.tsx b/components/login/TVBackIcon.tsx new file mode 100644 index 000000000..8cbc08d9c --- /dev/null +++ b/components/login/TVBackIcon.tsx @@ -0,0 +1,82 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVBackIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVBackIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index ddd83aa00..8af6a1de3 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -1,107 +1,37 @@ -import { Ionicons } from "@expo/vector-icons"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; -import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Alert, - Animated, - Easing, - Pressable, - ScrollView, - View, -} from "react-native"; -import { z } from "zod"; -import { Button } from "@/components/Button"; -import { Text } from "@/components/common/Text"; -import { TVInput } from "@/components/login/TVInput"; -import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; -import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; -import { TVPreviousServersList } from "@/components/login/TVPreviousServersList"; -import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal"; -import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle"; -import { useTVServerActionModal } from "@/hooks/useTVServerActionModal"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer"; import { type AccountSecurityType, + getPreviousServers, removeServerFromList, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; +import { TVAddServerForm } from "./TVAddServerForm"; +import { TVAddUserForm } from "./TVAddUserForm"; +import { TVPasswordEntryModal } from "./TVPasswordEntryModal"; +import { TVPINEntryModal } from "./TVPINEntryModal"; +import { TVSaveAccountModal } from "./TVSaveAccountModal"; +import { TVServerSelectionScreen } from "./TVServerSelectionScreen"; +import { TVUserSelectionScreen } from "./TVUserSelectionScreen"; -const CredentialsSchema = z.object({ - username: z.string().min(1, t("login.username_required")), -}); - -const TVBackButton: React.FC<{ - onPress: () => void; - label: string; - disabled?: boolean; -}> = ({ onPress, label, disabled = false }) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateFocus = (focused: boolean) => { - Animated.timing(scale, { - toValue: focused ? 1.05 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }; - - return ( - { - setIsFocused(true); - animateFocus(true); - }} - onBlur={() => { - setIsFocused(false); - animateFocus(false); - }} - style={{ alignSelf: "flex-start", marginBottom: 40 }} - disabled={disabled} - focusable={!disabled} - > - - - - {label} - - - - ); -}; +type TVLoginScreen = + | "server-selection" + | "user-selection" + | "add-server" + | "add-user"; export const TVLogin: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); - const { showServerActionModal } = useTVServerActionModal(); const { setServer, login, @@ -117,20 +47,33 @@ export const TVLogin: React.FC = () => { password: _password, } = params as { apiUrl: string; username: string; password: string }; + // Selected server persistence + const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom); + const [_previousServers, setPreviousServers] = + useMMKVString("previousServers"); + + // Get current servers list + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + // Current screen state + const [currentScreen, setCurrentScreen] = + useState("server-selection"); + + // Current selected server for user selection screen + const [currentServer, setCurrentServer] = useState(null); + const [serverName, setServerName] = useState(""); + + // Loading states const [loadingServerCheck, setLoadingServerCheck] = useState(false); const [loading, setLoading] = useState(false); - const [serverURL, setServerURL] = useState(_apiUrl || ""); - const [serverName, setServerName] = useState(""); - const [credentials, setCredentials] = useState<{ - username: string; - password: string; - }>({ - username: _username || "", - password: _password || "", - }); // Save account state - const [saveAccount, setSaveAccount] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); const [pendingLogin, setPendingLogin] = useState<{ username: string; @@ -140,20 +83,37 @@ export const TVLogin: React.FC = () => { // PIN/Password entry for saved accounts const [pinModalVisible, setPinModalVisible] = useState(false); const [passwordModalVisible, setPasswordModalVisible] = useState(false); - const [selectedServer, setSelectedServer] = useState( - null, - ); const [selectedAccount, setSelectedAccount] = useState(null); - // Server login trigger state - const [loginTriggerServer, setLoginTriggerServer] = - useState(null); - // Track if any modal is open to disable background focus const isAnyModalOpen = showSaveModal || pinModalVisible || passwordModalVisible; + // Refresh servers list helper + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + // Initialize on mount - check if we have a persisted server + useEffect(() => { + if (selectedTVServer) { + // Find the full server data from previousServers + const server = previousServers.find( + (s) => s.address === selectedTVServer.address, + ); + if (server) { + setCurrentServer(server); + setServerName(selectedTVServer.name || ""); + setCurrentScreen("user-selection"); + } else { + // Server no longer exists, clear persistence + setSelectedTVServer(null); + } + } + }, []); + // Auto login from URL params useEffect(() => { (async () => { @@ -161,7 +121,6 @@ export const TVLogin: React.FC = () => { await setServer({ address: _apiUrl }); setTimeout(() => { if (_username && _password) { - setCredentials({ username: _username, password: _password }); login(_username, _password); } }, 0); @@ -177,169 +136,7 @@ export const TVLogin: React.FC = () => { }); }, [serverName, navigation]); - const handleLogin = async () => { - const result = CredentialsSchema.safeParse(credentials); - if (!result.success) return; - - if (saveAccount) { - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - await performLogin(credentials.username, credentials.password); - } - }; - - const performLogin = async ( - username: string, - password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { - setLoading(true); - try { - await login(username, password, serverName, options); - } catch (error) { - if (error instanceof Error) { - Alert.alert(t("login.connection_failed"), error.message); - } else { - Alert.alert( - t("login.connection_failed"), - t("login.an_unexpected_error_occured"), - ); - } - } finally { - setLoading(false); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); - } - }; - - const handleQuickLoginWithSavedCredential = async ( - serverUrl: string, - userId: string, - ) => { - await loginWithSavedCredential(serverUrl, userId); - }; - - const handlePasswordLogin = async ( - serverUrl: string, - username: string, - password: string, - ) => { - await loginWithPassword(serverUrl, username, password); - }; - - const handleAddAccount = (server: SavedServer) => { - setServer({ address: server.address }); - if (server.name) { - setServerName(server.name); - } - }; - - const handlePinRequired = ( - server: SavedServer, - account: SavedServerAccount, - ) => { - setSelectedServer(server); - setSelectedAccount(account); - setPinModalVisible(true); - }; - - const handlePasswordRequired = ( - server: SavedServer, - account: SavedServerAccount, - ) => { - setSelectedServer(server); - setSelectedAccount(account); - setPasswordModalVisible(true); - }; - - const handlePinSuccess = async () => { - setPinModalVisible(false); - if (selectedServer && selectedAccount) { - await handleQuickLoginWithSavedCredential( - selectedServer.address, - selectedAccount.userId, - ); - } - setSelectedServer(null); - setSelectedAccount(null); - }; - - const handlePasswordSubmit = async (password: string) => { - if (selectedServer && selectedAccount) { - await handlePasswordLogin( - selectedServer.address, - selectedAccount.username, - password, - ); - } - setPasswordModalVisible(false); - setSelectedServer(null); - setSelectedAccount(null); - }; - - const handleForgotPIN = async () => { - if (selectedServer) { - setSelectedServer(null); - setSelectedAccount(null); - setPinModalVisible(false); - } - }; - - // Server action sheet handler - const handleServerAction = (server: SavedServer) => { - showServerActionModal({ - server, - onLogin: () => { - // Trigger the login flow in TVPreviousServersList - setLoginTriggerServer(server); - // Reset the trigger after a tick to allow re-triggering the same server - setTimeout(() => setLoginTriggerServer(null), 0); - }, - onDelete: () => { - Alert.alert( - t("server.remove_server"), - t("server.remove_server_description", { - server: server.name || server.address, - }), - [ - { - text: t("common.cancel"), - style: "cancel", - }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - await removeServerFromList(server.address); - }, - }, - ], - ); - }, - }); - }; - + // Server URL checking const checkUrl = useCallback(async (url: string) => { setLoadingServerCheck(true); const baseUrl = url.replace(/^https?:\/\//i, ""); @@ -387,27 +184,214 @@ export const TVLogin: React.FC = () => { return undefined; } - const handleConnect = useCallback(async (url: string) => { - url = url.trim().replace(/\/$/, ""); - console.log("[TVLogin] handleConnect called with:", url); - try { - const result = await checkUrl(url); - console.log("[TVLogin] checkUrl result:", result); - if (result === undefined) { + // Handle connecting to a new server + const handleConnect = useCallback( + async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + + // Update server list and get the new server data + refreshServers(); + + // Find or create server entry + const servers = getPreviousServers(); + const server = servers.find((s) => s.address === result); + + if (server) { + setCurrentServer(server); + setSelectedTVServer({ address: result, name: serverName }); + setCurrentScreen("user-selection"); + } + } catch (error) { + console.error("[TVLogin] Error in handleConnect:", error); + } + }, + [checkUrl, setServer, serverName, setSelectedTVServer], + ); + + // Handle selecting an existing server + const handleServerSelect = (server: SavedServer) => { + setCurrentServer(server); + setServerName(server.name || ""); + setSelectedTVServer({ address: server.address, name: server.name }); + setCurrentScreen("user-selection"); + }; + + // Handle changing server (back from user selection) + const handleChangeServer = () => { + setSelectedTVServer(null); + setCurrentServer(null); + setServerName(""); + removeServer(); + setCurrentScreen("server-selection"); + }; + + // Handle deleting a server + const handleDeleteServer = async (server: SavedServer) => { + await removeServerFromList(server.address); + refreshServers(); + // If we deleted the currently selected server, clear it + if (selectedTVServer?.address === server.address) { + setSelectedTVServer(null); + setCurrentServer(null); + } + }; + + // Handle user selection + const handleUserSelect = async (account: SavedServerAccount) => { + if (!currentServer) return; + + switch (account.securityType) { + case "none": + setLoading(true); + try { + await loginWithSavedCredential(currentServer.address, account.userId); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("add-user"), + }, + ], + ); + } finally { + setLoading(false); + } + break; + + case "pin": + setSelectedAccount(account); + setPinModalVisible(true); + break; + + case "password": + setSelectedAccount(account); + setPasswordModalVisible(true); + break; + } + }; + + // Handle PIN success + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (currentServer && selectedAccount) { + setLoading(true); + try { + await loginWithSavedCredential( + currentServer.address, + selectedAccount.userId, + ); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + ); + } finally { + setLoading(false); + } + } + setSelectedAccount(null); + }; + + // Handle password submit + const handlePasswordSubmit = async (password: string) => { + if (currentServer && selectedAccount) { + setLoading(true); + try { + await loginWithPassword( + currentServer.address, + selectedAccount.username, + password, + ); + } catch { Alert.alert( t("login.connection_failed"), - t("login.could_not_connect_to_server"), + t("login.invalid_username_or_password"), ); - return; + } finally { + setLoading(false); } - console.log("[TVLogin] Calling setServer with:", result); - await setServer({ address: result }); - console.log("[TVLogin] setServer completed successfully"); - } catch (error) { - console.error("[TVLogin] Error in handleConnect:", error); } - }, []); + setPasswordModalVisible(false); + setSelectedAccount(null); + }; + // Handle forgot PIN + const handleForgotPIN = async () => { + setSelectedAccount(null); + setPinModalVisible(false); + }; + + // Handle login with credentials (from add user form) + const handleLogin = async ( + username: string, + password: string, + saveAccount: boolean, + ) => { + if (!currentServer) return; + + if (saveAccount) { + setPendingLogin({ username, password }); + setShowSaveModal(true); + } else { + await performLogin(username, password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + // Handle quick connect const handleQuickConnect = async () => { try { const code = await initiateQuickConnect(); @@ -426,227 +410,89 @@ export const TVLogin: React.FC = () => { } }; - // Debug logging - console.log("[TVLogin] Render - api?.basePath:", api?.basePath); + // Render current screen + const renderScreen = () => { + // If API is connected but we're on server/user selection, + // it means we need to show add-user form + if (api?.basePath && currentScreen !== "add-user") { + // API is ready, show add-user form + return ( + + ); + } + + switch (currentScreen) { + case "server-selection": + return ( + setCurrentScreen("add-server")} + onDeleteServer={handleDeleteServer} + disabled={isAnyModalOpen} + /> + ); + + case "user-selection": + if (!currentServer) { + setCurrentScreen("server-selection"); + return null; + } + return ( + { + // Set the server in JellyfinProvider and go to add-user + setServer({ address: currentServer.address }); + setCurrentScreen("add-user"); + }} + onChangeServer={handleChangeServer} + disabled={isAnyModalOpen || loading} + /> + ); + + case "add-server": + return ( + setCurrentScreen("server-selection")} + loading={loadingServerCheck} + disabled={isAnyModalOpen} + /> + ); + + case "add-user": + return ( + { + removeServer(); + setCurrentScreen("user-selection"); + }} + loading={loading} + disabled={isAnyModalOpen} + /> + ); + + default: + return null; + } + }; return ( - - {api?.basePath ? ( - // ==================== CREDENTIALS SCREEN ==================== - - - {/* Back Button */} - removeServer()} - label={t("login.change_server")} - disabled={isAnyModalOpen} - /> - - {/* Title */} - - {serverName ? ( - <> - {`${t("login.login_to_title")} `} - {serverName} - - ) : ( - t("login.login_title") - )} - - - {api.basePath} - - - {/* Username Input - extra padding for focus scale */} - - - setCredentials((prev) => ({ ...prev, username: text })) - } - autoCapitalize='none' - autoCorrect={false} - textContentType='username' - returnKeyType='next' - hasTVPreferredFocus - disabled={isAnyModalOpen} - /> - - - {/* Password Input */} - - - setCredentials((prev) => ({ ...prev, password: text })) - } - secureTextEntry - autoCapitalize='none' - textContentType='password' - returnKeyType='done' - disabled={isAnyModalOpen} - /> - - - {/* Save Account Toggle */} - - - - - {/* Login Button */} - - - - - {/* Quick Connect Button */} - - - - ) : ( - // ==================== SERVER SELECTION SCREEN ==================== - - - {/* Logo */} - - - - - {/* Title */} - - Streamyfin - - - {t("server.enter_url_to_jellyfin_server")} - - - {/* Server URL Input - extra padding for focus scale */} - - - - - {/* Connect Button */} - - - - - {/* Previous Servers */} - - handleConnect(s.address)} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - onPinRequired={handlePinRequired} - onPasswordRequired={handlePasswordRequired} - onServerAction={handleServerAction} - loginServerOverride={loginTriggerServer} - disabled={isAnyModalOpen} - /> - - - - )} - + {renderScreen()} {/* Save Account Modal */} { setPendingLogin(null); }} onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} + username={pendingLogin?.username || ""} /> {/* PIN Entry Modal */} @@ -665,11 +511,10 @@ export const TVLogin: React.FC = () => { onClose={() => { setPinModalVisible(false); setSelectedAccount(null); - setSelectedServer(null); }} onSuccess={handlePinSuccess} onForgotPIN={handleForgotPIN} - serverUrl={selectedServer?.address || ""} + serverUrl={currentServer?.address || ""} userId={selectedAccount?.userId || ""} username={selectedAccount?.username || ""} /> @@ -680,7 +525,6 @@ export const TVLogin: React.FC = () => { onClose={() => { setPasswordModalVisible(false); setSelectedAccount(null); - setSelectedServer(null); }} onSubmit={handlePasswordSubmit} username={selectedAccount?.username || ""} diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx index 821bf689d..415cf2cf8 100644 --- a/components/login/TVPINEntryModal.tsx +++ b/components/login/TVPINEntryModal.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -11,8 +12,6 @@ import { View, } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; -import { useTVFocusAnimation } from "@/components/tv"; import { verifyAccountPIN } from "@/utils/secureCredentials"; interface TVPINEntryModalProps { @@ -25,40 +24,122 @@ interface TVPINEntryModalProps { username: string; } -// Forgot PIN Button -const TVForgotPINButton: React.FC<{ +// Number pad button +const NumberPadButton: React.FC<{ + value: string; onPress: () => void; - label: string; hasTVPreferredFocus?: boolean; -}> = ({ onPress, label, hasTVPreferredFocus = false }) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + isBackspace?: boolean; + disabled?: boolean; +}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); return ( { + setFocused(true); + animateTo(1.1); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} hasTVPreferredFocus={hasTVPreferredFocus} + disabled={disabled} + focusable={!disabled} > + {isBackspace ? ( + + ) : ( + + {value} + + )} + + + ); +}; + +// PIN dot indicator +const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({ + filled, + error, +}) => ( + +); + +// Forgot PIN link +const ForgotPINLink: React.FC<{ + onPress: () => void; + label: string; +}> = ({ onPress, label }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + {label} @@ -80,23 +161,21 @@ export const TVPINEntryModal: React.FC = ({ const { t } = useTranslation(); const [isReady, setIsReady] = useState(false); const [pinCode, setPinCode] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState(false); const [isVerifying, setIsVerifying] = useState(false); - const pinInputRef = useRef(null); const overlayOpacity = useRef(new Animated.Value(0)).current; - const sheetTranslateY = useRef(new Animated.Value(200)).current; + const contentScale = useRef(new Animated.Value(0.9)).current; const shakeAnimation = useRef(new Animated.Value(0)).current; useEffect(() => { if (visible) { - // Reset state when opening setPinCode(""); - setError(null); + setError(false); setIsVerifying(false); overlayOpacity.setValue(0); - sheetTranslateY.setValue(200); + contentScale.setValue(0.9); Animated.parallel([ Animated.timing(overlayOpacity, { @@ -105,32 +184,19 @@ export const TVPINEntryModal: React.FC = ({ easing: Easing.out(Easing.quad), useNativeDriver: true, }), - Animated.timing(sheetTranslateY, { - toValue: 0, + Animated.timing(contentScale, { + toValue: 1, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true, }), ]).start(); - } - }, [visible, overlayOpacity, sheetTranslateY]); - useEffect(() => { - if (visible) { const timer = setTimeout(() => setIsReady(true), 100); return () => clearTimeout(timer); } setIsReady(false); - }, [visible]); - - useEffect(() => { - if (visible && isReady) { - const timer = setTimeout(() => { - pinInputRef.current?.focus(); - }, 150); - return () => clearTimeout(timer); - } - }, [visible, isReady]); + }, [visible, overlayOpacity, contentScale]); const shake = () => { Animated.sequence([ @@ -157,33 +223,42 @@ export const TVPINEntryModal: React.FC = ({ ]).start(); }; - const handlePinChange = async (value: string) => { - setPinCode(value); - setError(null); + const handleNumberPress = async (num: string) => { + if (isVerifying || pinCode.length >= 4) return; + + setError(false); + const newPin = pinCode + num; + setPinCode(newPin); // Auto-verify when 4 digits entered - if (value.length === 4) { + if (newPin.length === 4) { setIsVerifying(true); try { - const isValid = await verifyAccountPIN(serverUrl, userId, value); + const isValid = await verifyAccountPIN(serverUrl, userId, newPin); if (isValid) { onSuccess(); setPinCode(""); } else { - setError(t("pin.invalid_pin")); + setError(true); shake(); - setPinCode(""); + setTimeout(() => setPinCode(""), 300); } } catch { - setError(t("pin.invalid_pin")); + setError(true); shake(); - setPinCode(""); + setTimeout(() => setPinCode(""), 300); } finally { setIsVerifying(false); } } }; + const handleBackspace = () => { + if (isVerifying) return; + setError(false); + setPinCode((prev) => prev.slice(0, -1)); + }; + const handleForgotPIN = () => { Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [ { text: t("common.cancel"), style: "cancel" }, @@ -204,11 +279,11 @@ export const TVPINEntryModal: React.FC = ({ - + = ({ style={styles.content} > {/* Header */} - - {t("pin.enter_pin")} - - {t("pin.enter_pin_for", { username })} - - + {t("pin.enter_pin")} + {username} - {/* PIN Input */} + {/* PIN Dots */} + + {[0, 1, 2, 3].map((i) => ( + i} error={error} /> + ))} + + + {/* Number Pad */} {isReady && ( - - - {error && {error}} - {isVerifying && ( - - {t("common.verifying")} - - )} - + + {/* Row 1: 1-3 */} + + handleNumberPress("1")} + hasTVPreferredFocus + disabled={isVerifying} + /> + handleNumberPress("2")} + disabled={isVerifying} + /> + handleNumberPress("3")} + disabled={isVerifying} + /> + + {/* Row 2: 4-6 */} + + handleNumberPress("4")} + disabled={isVerifying} + /> + handleNumberPress("5")} + disabled={isVerifying} + /> + handleNumberPress("6")} + disabled={isVerifying} + /> + + {/* Row 3: 7-9 */} + + handleNumberPress("7")} + disabled={isVerifying} + /> + handleNumberPress("8")} + disabled={isVerifying} + /> + handleNumberPress("9")} + disabled={isVerifying} + /> + + {/* Row 4: empty, 0, backspace */} + + + handleNumberPress("0")} + disabled={isVerifying} + /> + + + )} {/* Forgot PIN */} {isReady && onForgotPIN && ( - )} @@ -273,55 +407,81 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "flex-end", + backgroundColor: "rgba(0, 0, 0, 0.8)", + justifyContent: "center", + alignItems: "center", zIndex: 1000, }, - sheetContainer: { + contentContainer: { width: "100%", + maxWidth: 400, }, blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, + borderRadius: 24, overflow: "hidden", }, content: { - paddingTop: 24, - paddingBottom: 50, - overflow: "visible", - }, - header: { - paddingHorizontal: 48, - marginBottom: 24, + padding: 40, + alignItems: "center", }, title: { fontSize: 28, fontWeight: "bold", color: "#fff", - marginBottom: 4, + marginBottom: 8, + textAlign: "center", }, subtitle: { - fontSize: 16, + fontSize: 18, color: "rgba(255,255,255,0.6)", + marginBottom: 32, + textAlign: "center", }, - pinContainer: { - paddingHorizontal: 48, + pinDotsContainer: { + flexDirection: "row", + gap: 16, + marginBottom: 32, + }, + pinDot: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: "rgba(255,255,255,0.4)", + backgroundColor: "transparent", + }, + pinDotFilled: { + backgroundColor: "#fff", + borderColor: "#fff", + }, + pinDotError: { + borderColor: "#ef4444", + backgroundColor: "#ef4444", + }, + numberPad: { + gap: 12, + marginBottom: 24, + }, + numberRow: { + flexDirection: "row", + gap: 12, + }, + numberButton: { + width: 72, + height: 72, + borderRadius: 36, + justifyContent: "center", alignItems: "center", - marginBottom: 16, }, - errorText: { - color: "#ef4444", - fontSize: 14, - marginTop: 16, - textAlign: "center", + numberButtonPlaceholder: { + width: 72, + height: 72, }, - verifyingText: { - color: "rgba(255,255,255,0.6)", - fontSize: 14, - marginTop: 16, - textAlign: "center", + numberText: { + fontSize: 28, + fontWeight: "600", }, forgotContainer: { - alignItems: "center", + marginTop: 8, }, }); diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 3e5574a63..e4f0c358d 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -249,14 +249,16 @@ export const TVPasswordEntryModal: React.FC = ({ {/* Password Input */} {isReady && ( - {t("login.password")} + + {t("login.password_placeholder")} + { setPassword(text); setError(null); }} - placeholder={t("login.password")} + placeholder={t("login.password_placeholder")} onSubmitEditing={handleSubmit} hasTVPreferredFocus /> diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx deleted file mode 100644 index cc9ad1ff2..000000000 --- a/components/login/TVPreviousServersList.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type React from "react"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Alert, View } from "react-native"; -import { useMMKVString } from "react-native-mmkv"; -import { Text } from "@/components/common/Text"; -import { useScaledTVTypography } from "@/constants/TVTypography"; -import { useTVAccountSelectModal } from "@/hooks/useTVAccountSelectModal"; -import { - deleteAccountCredential, - getPreviousServers, - type SavedServer, - type SavedServerAccount, -} from "@/utils/secureCredentials"; -import { TVServerCard } from "./TVServerCard"; - -interface TVPreviousServersListProps { - onServerSelect: (server: SavedServer) => void; - onQuickLogin?: (serverUrl: string, userId: string) => Promise; - onPasswordLogin?: ( - serverUrl: string, - username: string, - password: string, - ) => Promise; - onAddAccount?: (server: SavedServer) => void; - onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void; - onPasswordRequired?: ( - server: SavedServer, - account: SavedServerAccount, - ) => void; - // Called when server is pressed to show action sheet (handled by parent) - onServerAction?: (server: SavedServer) => void; - // Called by parent when "Login" is selected from action sheet - loginServerOverride?: SavedServer | null; - // Disable all focusable elements (when a modal is open) - disabled?: boolean; -} - -export const TVPreviousServersList: React.FC = ({ - onServerSelect, - onQuickLogin, - onAddAccount, - onPinRequired, - onPasswordRequired, - onServerAction, - loginServerOverride, - disabled = false, -}) => { - const { t } = useTranslation(); - const typography = useScaledTVTypography(); - const { showAccountSelectModal } = useTVAccountSelectModal(); - const [_previousServers, setPreviousServers] = - useMMKVString("previousServers"); - const [loadingServer, setLoadingServer] = useState(null); - - const previousServers = useMemo(() => { - return JSON.parse(_previousServers || "[]") as SavedServer[]; - }, [_previousServers]); - - const refreshServers = () => { - const servers = getPreviousServers(); - setPreviousServers(JSON.stringify(servers)); - }; - - const handleAccountLogin = async ( - server: SavedServer, - account: SavedServerAccount, - ) => { - switch (account.securityType) { - case "none": - if (onQuickLogin) { - setLoadingServer(server.address); - try { - await onQuickLogin(server.address, account.userId); - } catch { - Alert.alert( - t("server.session_expired"), - t("server.please_login_again"), - [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], - ); - } finally { - setLoadingServer(null); - } - } - break; - - case "pin": - if (onPinRequired) { - onPinRequired(server, account); - } - break; - - case "password": - if (onPasswordRequired) { - onPasswordRequired(server, account); - } - break; - } - }; - - const handleDeleteAccount = async ( - server: SavedServer, - account: SavedServerAccount, - ) => { - Alert.alert( - t("server.remove_saved_login"), - t("server.remove_account_description", { username: account.username }), - [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.remove"), - style: "destructive", - onPress: async () => { - await deleteAccountCredential(server.address, account.userId); - refreshServers(); - }, - }, - ], - ); - }; - - const showAccountSelection = (server: SavedServer) => { - showAccountSelectModal({ - server, - onAccountSelect: (account) => handleAccountLogin(server, account), - onAddAccount: () => { - if (onAddAccount) { - onAddAccount(server); - } - }, - onDeleteAccount: (account) => handleDeleteAccount(server, account), - }); - }; - - // When parent triggers login via loginServerOverride, execute the login flow - useEffect(() => { - if (loginServerOverride) { - const accountCount = loginServerOverride.accounts?.length || 0; - - if (accountCount === 0) { - onServerSelect(loginServerOverride); - } else if (accountCount === 1) { - handleAccountLogin( - loginServerOverride, - loginServerOverride.accounts[0], - ); - } else { - showAccountSelection(loginServerOverride); - } - } - }, [loginServerOverride]); - - const handleServerPress = (server: SavedServer) => { - if (loadingServer) return; - - // If onServerAction is provided, delegate to parent for action sheet handling - if (onServerAction) { - onServerAction(server); - return; - } - - // Fallback: direct login flow (for backwards compatibility) - const accountCount = server.accounts?.length || 0; - if (accountCount === 0) { - onServerSelect(server); - } else if (accountCount === 1) { - handleAccountLogin(server, server.accounts[0]); - } else { - showAccountSelection(server); - } - }; - - const getServerSubtitle = (server: SavedServer): string | undefined => { - const accountCount = server.accounts?.length || 0; - - if (accountCount > 1) { - return t("server.accounts_count", { count: accountCount }); - } - if (accountCount === 1) { - return `${server.accounts[0].username} • ${t("server.saved")}`; - } - return server.name ? server.address : undefined; - }; - - const getSecurityIcon = ( - server: SavedServer, - ): keyof typeof Ionicons.glyphMap | null => { - const accountCount = server.accounts?.length || 0; - if (accountCount === 0) return null; - - if (accountCount > 1) { - return "people"; - } - - const account = server.accounts[0]; - switch (account.securityType) { - case "pin": - return "keypad"; - case "password": - return "lock-closed"; - default: - return "key"; - } - }; - - if (!previousServers.length) return null; - - return ( - - - {t("server.previous_servers")} - - - - {previousServers.map((server) => ( - handleServerPress(server)} - disabled={disabled} - /> - ))} - - - ); -}; diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx deleted file mode 100644 index 4325cdd62..000000000 --- a/components/login/TVServerCard.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import React, { useRef, useState } from "react"; -import { - ActivityIndicator, - Animated, - Easing, - Pressable, - View, -} from "react-native"; -import { Text } from "@/components/common/Text"; - -interface TVServerCardProps { - title: string; - subtitle?: string; - securityIcon?: keyof typeof Ionicons.glyphMap | null; - isLoading?: boolean; - onPress: () => void; - hasTVPreferredFocus?: boolean; - disabled?: boolean; -} - -export const TVServerCard: React.FC = ({ - title, - subtitle, - securityIcon, - isLoading, - onPress, - hasTVPreferredFocus, - disabled = false, -}) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - const glowOpacity = useRef(new Animated.Value(0)).current; - - const animateFocus = (focused: boolean) => { - Animated.parallel([ - Animated.timing(scale, { - toValue: focused ? 1.02 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(glowOpacity, { - toValue: focused ? 0.7 : 0, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(); - }; - - const handleFocus = () => { - setIsFocused(true); - animateFocus(true); - }; - - const handleBlur = () => { - setIsFocused(false); - animateFocus(false); - }; - - const isDisabled = disabled || isLoading; - - return ( - - - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - - - {isLoading ? ( - - ) : securityIcon ? ( - - - - - ) : ( - - )} - - - - - ); -}; diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx new file mode 100644 index 000000000..bfad0baf7 --- /dev/null +++ b/components/login/TVServerIcon.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVServerIconProps { + name: string; + address: string; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVServerIcon = React.forwardRef( + ( + { + name, + address, + onPress, + onLongPress, + hasTVPreferredFocus, + disabled = false, + }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + // Get the first letter of the server name (or address if no name) + const displayName = name || address; + const initial = displayName.charAt(0).toUpperCase(); + + return ( + + + + + {initial} + + + + + {displayName} + + + {name && ( + + {address.replace(/^https?:\/\//, "")} + + )} + + + ); + }, +); diff --git a/components/login/TVServerSelectionScreen.tsx b/components/login/TVServerSelectionScreen.tsx new file mode 100644 index 000000000..feff46dfe --- /dev/null +++ b/components/login/TVServerSelectionScreen.tsx @@ -0,0 +1,137 @@ +import { Image } from "expo-image"; +import { t } from "i18next"; +import React, { useMemo } from "react"; +import { Alert, ScrollView, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { SavedServer } from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVServerIcon } from "./TVServerIcon"; + +interface TVServerSelectionScreenProps { + onServerSelect: (server: SavedServer) => void; + onAddServer: () => void; + onDeleteServer: (server: SavedServer) => void; + disabled?: boolean; +} + +export const TVServerSelectionScreen: React.FC< + TVServerSelectionScreenProps +> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => { + const typography = useScaledTVTypography(); + const [_previousServers] = useMMKVString("previousServers"); + + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + const hasServers = previousServers.length > 0; + + const handleDeleteServer = (server: SavedServer) => { + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: server.name || server.address, + }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.delete"), + style: "destructive", + onPress: () => onDeleteServer(server), + }, + ], + ); + }; + + return ( + + + {/* Logo */} + + + + + {/* Title */} + + Streamyfin + + + {hasServers + ? t("server.select_your_server") + : t("server.add_server_to_get_started")} + + + {/* Server Icons Grid */} + + {previousServers.map((server, index) => ( + onServerSelect(server)} + onLongPress={() => handleDeleteServer(server)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + /> + ))} + + {/* Add Server Button */} + + + + + ); +}; diff --git a/components/login/TVUserIcon.tsx b/components/login/TVUserIcon.tsx new file mode 100644 index 000000000..cbfd67cf7 --- /dev/null +++ b/components/login/TVUserIcon.tsx @@ -0,0 +1,127 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; + +export interface TVUserIconProps { + username: string; + securityType: AccountSecurityType; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVUserIcon = React.forwardRef( + ( + { username, securityType, onPress, hasTVPreferredFocus, disabled = false }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const hasSecurityProtection = securityType !== "none"; + + return ( + + + + + + + + {/* Security badge */} + {hasSecurityProtection && ( + + + + )} + + + + {username} + + + + ); + }, +); diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx new file mode 100644 index 000000000..2c1a0c3a8 --- /dev/null +++ b/components/login/TVUserSelectionScreen.tsx @@ -0,0 +1,130 @@ +import { t } from "i18next"; +import React from "react"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVBackIcon } from "./TVBackIcon"; +import { TVUserIcon } from "./TVUserIcon"; + +interface TVUserSelectionScreenProps { + server: SavedServer; + onUserSelect: (account: SavedServerAccount) => void; + onAddUser: () => void; + onChangeServer: () => void; + disabled?: boolean; +} + +export const TVUserSelectionScreen: React.FC = ({ + server, + onUserSelect, + onAddUser, + onChangeServer, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + + const accounts = server.accounts || []; + const hasAccounts = accounts.length > 0; + + return ( + + + {/* Server Info Header */} + + + {server.name || server.address} + + {server.name && ( + + {server.address.replace(/^https?:\/\//, "")} + + )} + + {hasAccounts + ? t("login.select_user") + : t("login.add_user_to_login")} + + + + {/* User Icons Grid with Back and Add buttons */} + + {/* Back/Change Server Button (left) */} + + + {/* User Icons */} + {accounts.map((account, index) => ( + onUserSelect(account)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + /> + ))} + + {/* Add User Button (right) */} + + + + + ); +}; diff --git a/docs/tv-modal-guide.md b/docs/tv-modal-guide.md new file mode 100644 index 000000000..a1b57e9bd --- /dev/null +++ b/docs/tv-modal-guide.md @@ -0,0 +1,416 @@ +# TV Modal Guide + +This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native. + +## The Problem + +On TV platforms, modals have unique challenges: +- The hardware back button must work correctly to dismiss modals +- Focus management must be handled explicitly +- React Native's `Modal` component breaks the TV focus chain +- Overlay/absolute-positioned modals don't handle back button correctly + +## Navigation-Based Modal Pattern (Recommended) + +For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation. + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ 1. Jotai Atom (state) │ +│ Stores modal data/params │ +├─────────────────────────────────────┤ +│ 2. Hook (trigger) │ +│ Sets atom + calls router.push() │ +├─────────────────────────────────────┤ +│ 3. Page File (UI) │ +│ Reads atom, renders modal │ +│ Clears atom on unmount │ +├─────────────────────────────────────┤ +│ 4. Stack.Screen (config) │ +│ presentation: transparentModal │ +│ animation: fade │ +└─────────────────────────────────────┘ +``` + +### Step 1: Create the Atom + +Create a Jotai atom to store the modal state/data: + +```typescript +// utils/atoms/tvExampleModal.ts +import { atom } from "jotai"; + +export interface TVExampleModalData { + itemId: string; + title: string; + // ... other data the modal needs +} + +export const tvExampleModalAtom = atom(null); +``` + +### Step 2: Create the Hook + +Create a hook that sets the atom and navigates to the modal: + +```typescript +// hooks/useTVExampleModal.ts +import { useSetAtom } from "jotai"; +import { router } from "expo-router"; +import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal"; + +export const useTVExampleModal = () => { + const setModalData = useSetAtom(tvExampleModalAtom); + + const openModal = (data: TVExampleModalData) => { + setModalData(data); + router.push("/tv-example-modal"); + }; + + return { openModal }; +}; +``` + +### Step 3: Create the Modal Page + +Create a page file that reads the atom and renders the modal UI: + +```typescript +// app/(auth)/tv-example-modal.tsx +import { useEffect } from "react"; +import { View, Pressable, Text } from "react-native"; +import { useAtom } from "jotai"; +import { router } from "expo-router"; +import { BlurView } from "expo-blur"; +import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal"; + +export default function TVExampleModal() { + const [modalData, setModalData] = useAtom(tvExampleModalAtom); + + // Clear atom on unmount + useEffect(() => { + return () => { + setModalData(null); + }; + }, [setModalData]); + + // Handle case where modal is opened without data + if (!modalData) { + router.back(); + return null; + } + + return ( + + {/* Background overlay */} + router.back()} + /> + + {/* Modal content */} + + + {modalData.title} + + {/* Modal content here */} + + router.back()} + hasTVPreferredFocus + style={({ focused }) => ({ + marginTop: 24, + padding: 16, + borderRadius: 8, + backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)", + })} + > + {({ focused }) => ( + + Close + + )} + + + + ); +} +``` + +### Step 4: Add Stack.Screen Configuration + +Add the modal route to `app/_layout.tsx`: + +```typescript +// In app/_layout.tsx, inside your Stack navigator + +``` + +### Usage + +```typescript +// In any component +import { useTVExampleModal } from "@/hooks/useTVExampleModal"; + +const MyComponent = () => { + const { openModal } = useTVExampleModal(); + + return ( + openModal({ itemId: "123", title: "Example" })} + > + Open Modal + + ); +}; +``` + +### Reference Implementation + +See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example. + +--- + +## Bottom Sheet Pattern (Inline Overlays) + +For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for: +- Dropdown selectors +- Quick action menus +- Option pickers + +### Key Principles + +1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain +2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad) +3. **Disable background focus** - Prevent focus flickering between overlay and background + +### Implementation + +```typescript +import { useState } from "react"; +import { View, ScrollView, Pressable, Text } from "react-native"; +import { BlurView } from "expo-blur"; + +const TVOptionSelector: React.FC<{ + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isOpen: boolean; + onClose: () => void; +}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => { + if (!isOpen) return null; + + const selectedIndex = options.findIndex(o => o.value === selectedValue); + + return ( + + + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + ); +}; +``` + +### Option Card Component + +```typescript +import { useState, useRef, useEffect } from "react"; +import { Pressable, Text, Animated } from "react-native"; + +const TVOptionCard: React.FC<{ + label: string; + isSelected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + useNativeDriver: true, + tension: 50, + friction: 7, + }).start(); + }; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; +``` + +### Reference Implementation + +See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`. + +--- + +## Focus Management for Overlays + +**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop. + +### Solution + +Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: + +```typescript +// 1. Track modal state +const [openModal, setOpenModal] = useState(null); +const isModalOpen = openModal !== null; + +// 2. Each focusable component accepts disabled prop +const TVFocusableButton: React.FC<{ + onPress: () => void; + disabled?: boolean; +}> = ({ onPress, disabled }) => ( + + {/* content */} + +); + +// 3. Pass disabled to all background components when modal is open + +``` + +### Reference Implementation + +See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. + +--- + +## Focus Trapping + +For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props: + +```typescript +import { TVFocusGuideView } from "react-native"; + + + {/* Modal content - focus cannot escape */} + +``` + +**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation. + +--- + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning | +| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables | +| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element | +| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options | +| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup | + +--- + +## When to Use Which Pattern + +| Scenario | Pattern | +|----------|---------| +| Full-screen modal with back button | Navigation-based modal | +| Confirmation dialogs | Navigation-based modal | +| Option selectors / dropdowns | Bottom sheet (inline) | +| Quick action menus | Bottom sheet (inline) | +| Complex forms | Navigation-based modal | diff --git a/hooks/useTVAccountActionModal.ts b/hooks/useTVAccountActionModal.ts new file mode 100644 index 000000000..97db7ac50 --- /dev/null +++ b/hooks/useTVAccountActionModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountActionModalParams { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVAccountActionModal = () => { + const router = useRouter(); + + const showAccountActionModal = useCallback( + (params: ShowAccountActionModalParams) => { + store.set(tvAccountActionModalAtom, { + server: params.server, + account: params.account, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-account-action-modal"); + }, + [router], + ); + + return { showAccountActionModal }; +}; diff --git a/hooks/useTVAccountSelectModal.ts b/hooks/useTVAccountSelectModal.ts index 3be0f5d6a..3bc61ed77 100644 --- a/hooks/useTVAccountSelectModal.ts +++ b/hooks/useTVAccountSelectModal.ts @@ -9,9 +9,9 @@ import { store } from "@/utils/store"; interface ShowAccountSelectModalParams { server: SavedServer; - onAccountSelect: (account: SavedServerAccount) => void; + onAccountAction: (account: SavedServerAccount) => void; onAddAccount: () => void; - onDeleteAccount: (account: SavedServerAccount) => void; + onDeleteServer: () => void; } export const useTVAccountSelectModal = () => { @@ -21,9 +21,9 @@ export const useTVAccountSelectModal = () => { (params: ShowAccountSelectModalParams) => { store.set(tvAccountSelectModalAtom, { server: params.server, - onAccountSelect: params.onAccountSelect, + onAccountAction: params.onAccountAction, onAddAccount: params.onAddAccount, - onDeleteAccount: params.onDeleteAccount, + onDeleteServer: params.onDeleteServer, }); router.push("/tv-account-select-modal"); }, diff --git a/hooks/useTVServerActionModal.ts b/hooks/useTVServerActionModal.ts deleted file mode 100644 index f0da43f13..000000000 --- a/hooks/useTVServerActionModal.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useCallback } from "react"; -import useRouter from "@/hooks/useAppRouter"; -import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal"; -import type { SavedServer } from "@/utils/secureCredentials"; -import { store } from "@/utils/store"; - -interface ShowServerActionModalParams { - server: SavedServer; - onLogin: () => void; - onDelete: () => void; -} - -export const useTVServerActionModal = () => { - const router = useRouter(); - - const showServerActionModal = useCallback( - (params: ShowServerActionModalParams) => { - store.set(tvServerActionModalAtom, { - server: params.server, - onLogin: params.onLogin, - onDelete: params.onDelete, - }); - router.push("/tv-server-action-modal"); - }, - [router], - ); - - return { showServerActionModal }; -}; diff --git a/translations/en.json b/translations/en.json index 651a1aa39..bd25ba87b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -4,6 +4,9 @@ "error_title": "Error", "login_title": "Log In", "login_to_title": "Log in to", + "select_user": "Select a user to log in", + "add_user_to_login": "Add a user to log in", + "add_user": "Add User", "username_placeholder": "Username", "password_placeholder": "Password", "login_button": "Log In", @@ -44,7 +47,11 @@ "add_account": "Add Account", "remove_account_description": "This will remove the saved credentials for {{username}}.", "remove_server": "Remove Server", - "remove_server_description": "This will remove {{server}} and all saved accounts from your list." + "remove_server_description": "This will remove {{server}} and all saved accounts from your list.", + "select_your_server": "Select Your Server", + "add_server_to_get_started": "Add a server to get started", + "add_server": "Add Server", + "change_server": "Change Server" }, "save_account": { "title": "Save Account", @@ -115,7 +122,7 @@ "switch_user": { "title": "Switch User", "account": "Account", - "switch_user": "Switch User", + "switch_user": "Switch User on This Server", "current": "current" }, "categories": { diff --git a/utils/atoms/selectedTVServer.ts b/utils/atoms/selectedTVServer.ts new file mode 100644 index 000000000..c5d25479c --- /dev/null +++ b/utils/atoms/selectedTVServer.ts @@ -0,0 +1,60 @@ +import { atom } from "jotai"; +import { storage } from "../mmkv"; + +const STORAGE_KEY = "selectedTVServer"; + +export interface SelectedTVServerState { + address: string; + name?: string; +} + +/** + * Load the selected TV server from MMKV storage. + */ +function loadSelectedTVServer(): SelectedTVServerState | null { + const stored = storage.getString(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored) as SelectedTVServerState; + } catch { + return null; + } + } + return null; +} + +/** + * Save the selected TV server to MMKV storage. + */ +function saveSelectedTVServer(server: SelectedTVServerState | null): void { + if (server) { + storage.set(STORAGE_KEY, JSON.stringify(server)); + } else { + storage.remove(STORAGE_KEY); + } +} + +/** + * Base atom holding the selected TV server state. + */ +const baseSelectedTVServerAtom = atom( + loadSelectedTVServer(), +); + +/** + * Derived atom that persists changes to MMKV storage. + */ +export const selectedTVServerAtom = atom( + (get) => get(baseSelectedTVServerAtom), + (_get, set, newValue: SelectedTVServerState | null) => { + saveSelectedTVServer(newValue); + set(baseSelectedTVServerAtom, newValue); + }, +); + +/** + * Clear the selected TV server (used when changing servers). + */ +export function clearSelectedTVServer(): void { + storage.remove(STORAGE_KEY); +} diff --git a/utils/atoms/tvAccountActionModal.ts b/utils/atoms/tvAccountActionModal.ts new file mode 100644 index 000000000..c9532a7fb --- /dev/null +++ b/utils/atoms/tvAccountActionModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountActionModalState = { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} | null; + +export const tvAccountActionModalAtom = atom(null); diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts index 3cafa61e2..9fd8bf20e 100644 --- a/utils/atoms/tvAccountSelectModal.ts +++ b/utils/atoms/tvAccountSelectModal.ts @@ -6,9 +6,9 @@ import type { export type TVAccountSelectModalState = { server: SavedServer; - onAccountSelect: (account: SavedServerAccount) => void; + onAccountAction: (account: SavedServerAccount) => void; onAddAccount: () => void; - onDeleteAccount: (account: SavedServerAccount) => void; + onDeleteServer: () => void; } | null; export const tvAccountSelectModalAtom = atom(null); diff --git a/utils/atoms/tvServerActionModal.ts b/utils/atoms/tvServerActionModal.ts deleted file mode 100644 index 38d99e839..000000000 --- a/utils/atoms/tvServerActionModal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { atom } from "jotai"; -import type { SavedServer } from "@/utils/secureCredentials"; - -export type TVServerActionModalState = { - server: SavedServer; - onLogin: () => void; - onDelete: () => void; -} | null; - -export const tvServerActionModalAtom = atom(null); From 1ec887c29e1c7aee45e0a7c6c0ca0b8acefc992f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 11:49:15 +0100 Subject: [PATCH 158/309] feat(tv): new login design --- components/login/TVServerIcon.tsx | 123 ++++++++++++++++++--- components/login/TVUserIcon.tsx | 53 +++++++-- components/login/TVUserSelectionScreen.tsx | 45 +++++++- providers/JellyfinProvider.tsx | 29 ++++- utils/jellyfin/image/getUserImageUrl.ts | 32 ++++++ utils/secureCredentials.ts | 20 +++- 6 files changed, 273 insertions(+), 29 deletions(-) create mode 100644 utils/jellyfin/image/getUserImageUrl.ts diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx index bfad0baf7..1ef01c51e 100644 --- a/components/login/TVServerIcon.tsx +++ b/components/login/TVServerIcon.tsx @@ -1,9 +1,83 @@ -import React from "react"; +import { LinearGradient } from "expo-linear-gradient"; +import React, { useMemo } from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useScaledTVTypography } from "@/constants/TVTypography"; +// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes +const SERVER_GRADIENTS: [string, string][] = [ + ["#00D4FF", "#0066FF"], // Cyan to Blue + ["#FF00E5", "#7B00FF"], // Magenta to Purple + ["#00FF94", "#00B4D8"], // Neon Green to Cyan + ["#FF6B35", "#F72585"], // Orange to Pink + ["#4CC9F0", "#7209B7"], // Sky Blue to Violet + ["#06D6A0", "#118AB2"], // Mint to Ocean Blue + ["#FFD60A", "#FF006E"], // Yellow to Hot Pink + ["#8338EC", "#3A86FF"], // Purple to Blue + ["#FB5607", "#FFBE0B"], // Orange to Gold + ["#00F5D4", "#00BBF9"], // Aqua to Azure + ["#F15BB5", "#9B5DE5"], // Pink to Lavender + ["#00C49A", "#00509D"], // Teal to Navy + ["#E63946", "#F4A261"], // Red to Peach + ["#2EC4B6", "#011627"], // Turquoise to Dark Blue + ["#FF0099", "#493240"], // Hot Pink to Plum + ["#11998E", "#38EF7D"], // Teal to Lime + ["#FC466B", "#3F5EFB"], // Pink to Indigo + ["#C471ED", "#12C2E9"], // Orchid to Sky + ["#F857A6", "#FF5858"], // Pink to Coral + ["#00B09B", "#96C93D"], // Emerald to Lime + ["#7F00FF", "#E100FF"], // Violet to Magenta + ["#1FA2FF", "#12D8FA"], // Blue to Cyan + ["#F09819", "#EDDE5D"], // Orange to Yellow + ["#FF416C", "#FF4B2B"], // Pink to Red Orange + ["#654EA3", "#EAAFC8"], // Purple to Rose + ["#00C6FF", "#0072FF"], // Light Blue to Blue + ["#F7971E", "#FFD200"], // Orange to Gold + ["#56AB2F", "#A8E063"], // Green to Lime + ["#DA22FF", "#9733EE"], // Magenta to Purple + ["#02AAB0", "#00CDAC"], // Teal variations + ["#ED213A", "#93291E"], // Red to Dark Red + ["#FDC830", "#F37335"], // Yellow to Orange + ["#00B4DB", "#0083B0"], // Ocean Blue + ["#C33764", "#1D2671"], // Berry to Navy + ["#E55D87", "#5FC3E4"], // Pink to Sky Blue + ["#403B4A", "#E7E9BB"], // Dark to Cream + ["#F2709C", "#FF9472"], // Rose to Peach + ["#1D976C", "#93F9B9"], // Forest to Mint + ["#CC2B5E", "#753A88"], // Crimson to Purple + ["#42275A", "#734B6D"], // Plum shades + ["#BDC3C7", "#2C3E50"], // Silver to Slate + ["#DE6262", "#FFB88C"], // Salmon to Apricot + ["#06BEB6", "#48B1BF"], // Teal shades + ["#EB3349", "#F45C43"], // Red to Orange Red + ["#DD5E89", "#F7BB97"], // Pink to Tan + ["#56CCF2", "#2F80ED"], // Sky to Blue + ["#007991", "#78FFD6"], // Deep Teal to Mint + ["#C6FFDD", "#FBD786"], // Mint to Yellow + ["#F953C6", "#B91D73"], // Pink to Magenta + ["#B24592", "#F15F79"], // Purple to Coral +]; + +// Generate a consistent gradient index based on URL (deterministic hash) +// Uses cyrb53 hash - fast and good distribution +const getGradientForString = (str: string): [string, string] => { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); + const index = Math.abs(hash) % SERVER_GRADIENTS.length; + return SERVER_GRADIENTS[index]; +}; + export interface TVServerIconProps { name: string; address: string; @@ -33,6 +107,14 @@ export const TVServerIcon = React.forwardRef( const displayName = name || address; const initial = displayName.charAt(0).toUpperCase(); + // Get a consistent gradient based on the server URL (deterministic) + // Use address as primary key, fallback to name + displayName for uniqueness + const hashKey = address || name || displayName; + const [gradientStart, gradientEnd] = useMemo( + () => getGradientForString(hashKey), + [hashKey], + ); + return ( ( { alignItems: "center", width: 160, - shadowColor: "#fff", + shadowColor: gradientStart, shadowOffset: { width: 0, height: 0 }, - shadowOpacity: focused ? 0.5 : 0, - shadowRadius: focused ? 16 : 0, + shadowOpacity: focused ? 0.7 : 0, + shadowRadius: focused ? 24 : 0, }, ]} > @@ -63,25 +145,36 @@ export const TVServerIcon = React.forwardRef( height: 140, borderRadius: 70, overflow: "hidden", - backgroundColor: focused - ? "rgba(255,255,255,0.2)" - : "rgba(255,255,255,0.1)", marginBottom: 14, borderWidth: focused ? 3 : 0, borderColor: "#fff", - justifyContent: "center", - alignItems: "center", }} > - - {initial} - + + {initial} + + void; hasTVPreferredFocus?: boolean; disabled?: boolean; + serverAddress?: string; + userId?: string; + primaryImageTag?: string; } export const TVUserIcon = React.forwardRef( ( - { username, securityType, onPress, hasTVPreferredFocus, disabled = false }, + { + username, + securityType, + onPress, + hasTVPreferredFocus, + disabled = false, + serverAddress, + userId, + primaryImageTag, + }, ref, ) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation(); + const [imageError, setImageError] = useState(false); const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { switch (securityType) { @@ -36,6 +51,16 @@ export const TVUserIcon = React.forwardRef( const hasSecurityProtection = securityType !== "none"; + const imageUrl = + serverAddress && userId && primaryImageTag && !imageError + ? getUserImageUrl({ + serverAddress, + userId, + primaryImageTag, + width: 280, + }) + : null; + return ( ( { alignItems: "center", width: 160, + overflow: "visible", shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.5 : 0, @@ -76,13 +102,22 @@ export const TVUserIcon = React.forwardRef( alignItems: "center", }} > - + {imageUrl ? ( + setImageError(true)} + /> + ) : ( + + )} {/* Security badge */} diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx index 2c1a0c3a8..d9f74b371 100644 --- a/components/login/TVUserSelectionScreen.tsx +++ b/components/login/TVUserSelectionScreen.tsx @@ -1,6 +1,6 @@ import { t } from "i18next"; -import React from "react"; -import { ScrollView, View } from "react-native"; +import React, { useEffect } from "react"; +import { BackHandler, Platform, ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; import type { @@ -19,6 +19,18 @@ interface TVUserSelectionScreenProps { disabled?: boolean; } +// TV event handler with fallback for non-TV platforms +let useTVEventHandler: (callback: (evt: any) => void) => void; +if (Platform.isTV) { + try { + useTVEventHandler = require("react-native").useTVEventHandler; + } catch { + useTVEventHandler = () => {}; + } +} else { + useTVEventHandler = () => {}; +} + export const TVUserSelectionScreen: React.FC = ({ server, onUserSelect, @@ -31,6 +43,32 @@ export const TVUserSelectionScreen: React.FC = ({ const accounts = server.accounts || []; const hasAccounts = accounts.length > 0; + // Handle TV remote back/menu button + useTVEventHandler((evt) => { + if (!evt || disabled) return; + if (evt.eventType === "menu" || evt.eventType === "back") { + onChangeServer(); + } + }); + + // Handle Android TV back button + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (disabled) return false; + onChangeServer(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onChangeServer, disabled]); + return ( = ({ onPress={() => onUserSelect(account)} hasTVPreferredFocus={index === 0} disabled={disabled} + serverAddress={server.address} + userId={account.userId} + primaryImageTag={account.primaryImageTag} /> ))} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 97ba07e0a..97f7a4e25 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -29,6 +29,7 @@ import { writeErrorLog, writeInfoLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; import { type AccountSecurityType, + addAccountToServer, addServerToList, deleteAccountCredential, getAccountCredential, @@ -287,6 +288,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ savedAt: Date.now(), securityType, pinHash, + primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined, }); } @@ -400,6 +402,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("token", credential.token); storage.set("user", JSON.stringify(response.data)); + // Update account info (in case user changed their avatar) + if (response.data.PrimaryImageTag !== credential.primaryImageTag) { + addAccountToServer(serverUrl, credential.serverName, { + userId: credential.userId, + username: credential.username, + securityType: credential.securityType, + savedAt: credential.savedAt, + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } + // Refresh plugin settings await refreshStreamyfinPluginSettings(); } catch (error) { @@ -451,11 +464,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("serverUrl", serverUrl); storage.set("token", auth.data.AccessToken); - // Update the saved credential with new token + // Update the saved credential with new token and image tag await updateAccountToken( serverUrl, auth.data.User.Id || "", auth.data.AccessToken, + auth.data.User.PrimaryImageTag ?? undefined, ); // Refresh plugin settings @@ -542,6 +556,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ username: storedUser.Name, savedAt: Date.now(), securityType: "none", + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } else if ( + response.data.PrimaryImageTag !== + existingCredential.primaryImageTag + ) { + // Update image tag if it has changed + addAccountToServer(serverUrl, existingCredential.serverName, { + userId: existingCredential.userId, + username: existingCredential.username, + securityType: existingCredential.securityType, + savedAt: existingCredential.savedAt, + primaryImageTag: response.data.PrimaryImageTag ?? undefined, }); } } diff --git a/utils/jellyfin/image/getUserImageUrl.ts b/utils/jellyfin/image/getUserImageUrl.ts new file mode 100644 index 000000000..89e51f6a1 --- /dev/null +++ b/utils/jellyfin/image/getUserImageUrl.ts @@ -0,0 +1,32 @@ +/** + * Retrieves the profile image URL for a Jellyfin user. + * + * @param serverAddress - The Jellyfin server base URL. + * @param userId - The user's ID. + * @param primaryImageTag - The user's primary image tag (required for the image to exist). + * @param width - The desired image width (default: 280). + * @returns The image URL or null if no image tag is provided. + */ +export const getUserImageUrl = ({ + serverAddress, + userId, + primaryImageTag, + width = 280, +}: { + serverAddress: string; + userId: string; + primaryImageTag?: string | null; + width?: number; +}): string | null => { + if (!primaryImageTag) { + return null; + } + + const params = new URLSearchParams({ + tag: primaryImageTag, + quality: "90", + width: String(width), + }); + + return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`; +}; diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index f64a56d7e..bb5f7713e 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -22,6 +22,7 @@ export interface ServerCredential { savedAt: number; securityType: AccountSecurityType; pinHash?: string; + primaryImageTag?: string; } /** @@ -32,6 +33,7 @@ export interface SavedServerAccount { username: string; securityType: AccountSecurityType; savedAt: number; + primaryImageTag?: string; } /** @@ -131,6 +133,7 @@ export async function saveAccountCredential( username: credential.username, securityType: credential.securityType, savedAt: credential.savedAt, + primaryImageTag: credential.primaryImageTag, }); } @@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise { /** * Add or update an account in a server's accounts list. */ -function addAccountToServer( +export function addAccountToServer( serverUrl: string, serverName: string, account: SavedServerAccount, @@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise { } /** - * Update account's token after successful login. + * Update account's token and optionally other fields after successful login. */ export async function updateAccountToken( serverUrl: string, userId: string, newToken: string, + primaryImageTag?: string, ): Promise { const credential = await getAccountCredential(serverUrl, userId); if (credential) { credential.token = newToken; credential.savedAt = Date.now(); + if (primaryImageTag !== undefined) { + credential.primaryImageTag = primaryImageTag; + } const key = credentialKey(serverUrl, userId); await SecureStore.setItemAsync(key, JSON.stringify(credential)); + + // Also update the account info in the server list + addAccountToServer(serverUrl, credential.serverName, { + userId: credential.userId, + username: credential.username, + securityType: credential.securityType, + savedAt: credential.savedAt, + primaryImageTag: credential.primaryImageTag, + }); } } From 4601ae20b6bf309d664524ec7af2142ca3d9b4fe Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 12:58:44 +0100 Subject: [PATCH 159/309] fix(tv): stop quick connect polling on login page exit --- components/login/TVLogin.tsx | 8 ++++++++ providers/JellyfinProvider.tsx | 20 ++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 8af6a1de3..182d25d9d 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -37,6 +37,7 @@ export const TVLogin: React.FC = () => { login, removeServer, initiateQuickConnect, + stopQuickConnectPolling, loginWithSavedCredential, loginWithPassword, } = useJellyfin(); @@ -114,6 +115,13 @@ export const TVLogin: React.FC = () => { } }, []); + // Stop Quick Connect polling when leaving the login page + useEffect(() => { + return () => { + stopQuickConnectPolling(); + }; + }, [stopQuickConnectPolling]); + // Auto login from URL params useEffect(() => { (async () => { diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 97f7a4e25..1b066ba3c 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -66,6 +66,7 @@ interface JellyfinContextValue { ) => Promise; logout: () => Promise; initiateQuickConnect: () => Promise; + stopQuickConnectPolling: () => void; loginWithSavedCredential: ( serverUrl: string, userId: string, @@ -148,6 +149,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ } }, [api, deviceId, headers]); + const stopQuickConnectPolling = useCallback(() => { + setIsPolling(false); + setSecret(null); + }, []); + const pollQuickConnect = useCallback(async () => { if (!api || !secret || !jellyfin) return; @@ -180,10 +186,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ } return false; } catch (error) { - if (error instanceof AxiosError && error.response?.status === 400) { - setIsPolling(false); - setSecret(null); - throw new Error("The code has expired. Please try again."); + if (error instanceof AxiosError) { + if (error.response?.status === 400 || error.response?.status === 404) { + setIsPolling(false); + setSecret(null); + if (error.response?.status === 400) { + throw new Error("The code has expired. Please try again."); + } + return false; + } } console.error("Error polling Quick Connect:", error); throw error; @@ -591,6 +602,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ loginMutation.mutateAsync({ username, password, serverName, options }), logout: () => logoutMutation.mutateAsync(), initiateQuickConnect, + stopQuickConnectPolling, loginWithSavedCredential: (serverUrl, userId) => loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }), loginWithPassword: (serverUrl, username, password) => From 4afab8d94a0482497699f26d72283295e84c894f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 13:42:52 +0100 Subject: [PATCH 160/309] fix(mpv): pause playback when tvOS app enters background --- modules/mpv-player/ios/MpvPlayerView.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 1dd2555f0..a06558033 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -67,6 +67,7 @@ class MpvPlayerView: ExpoView { private var cachedDuration: Double = 0 private var intendedPlayState: Bool = false private var _isZoomedToFill: Bool = false + private var appStateObserver: NSObjectProtocol? required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -114,6 +115,17 @@ class MpvPlayerView: ExpoView { } catch { onError(["error": "Failed to start renderer: \(error.localizedDescription)"]) } + + // Pause playback when app enters background on tvOS + #if os(tvOS) + appStateObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.pause() + } + #endif } override func layoutSubviews() { @@ -325,6 +337,9 @@ class MpvPlayerView: ExpoView { } deinit { + if let observer = appStateObserver { + NotificationCenter.default.removeObserver(observer) + } #if os(tvOS) resetDisplayCriteria() #endif From 717186e13e8ba9f585e0b815146d47d9f5e007f4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 15:00:38 +0100 Subject: [PATCH 161/309] fix(tv): set node version --- plugins/withTVXcodeEnv.js | 76 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.js index ccdbc8424..86f367552 100644 --- a/plugins/withTVXcodeEnv.js +++ b/plugins/withTVXcodeEnv.js @@ -1,13 +1,16 @@ const { withDangerousMod } = require("@expo/config-plugins"); +const { execSync } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); /** - * Expo config plugin that adds EXPO_TV=1 to .xcode.env.local for TV builds. + * Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds. * * This ensures that when building directly from Xcode (without using `bun run ios:tv`), * Metro bundler knows it's a TV build and properly excludes unsupported modules * like react-native-track-player. + * + * It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions. */ const withTVXcodeEnv = (config) => { // Only apply for TV builds @@ -27,21 +30,88 @@ const withTVXcodeEnv = (config) => { content = fs.readFileSync(xcodeEnvLocalPath, "utf-8"); } + let modified = false; + + // Add NODE_BINARY if not already present (needed for nvm users) + if (!content.includes("export NODE_BINARY=")) { + const nodePath = getNodeBinaryPath(); + if (nodePath) { + if (content.length > 0 && !content.endsWith("\n")) { + content += "\n"; + } + content += `export NODE_BINARY=${nodePath}\n`; + modified = true; + console.log( + `[withTVXcodeEnv] Added NODE_BINARY=${nodePath} to .xcode.env.local`, + ); + } + } + // Add EXPO_TV=1 if not already present const expoTvExport = "export EXPO_TV=1"; if (!content.includes(expoTvExport)) { - // Ensure we have a newline at the end before adding if (content.length > 0 && !content.endsWith("\n")) { content += "\n"; } content += `${expoTvExport}\n`; - fs.writeFileSync(xcodeEnvLocalPath, content); + modified = true; console.log("[withTVXcodeEnv] Added EXPO_TV=1 to .xcode.env.local"); } + if (modified) { + fs.writeFileSync(xcodeEnvLocalPath, content); + } + return config; }, ]); }; +/** + * Get the actual node binary path, handling nvm installations. + */ +function getNodeBinaryPath() { + try { + // First try to get node path directly (works for non-nvm installs) + const directPath = execSync("which node 2>/dev/null", { + encoding: "utf-8", + }).trim(); + if (directPath && fs.existsSync(directPath)) { + return directPath; + } + } catch { + // Ignore errors + } + + try { + // For nvm users, source nvm and get the path + const nvmPath = execSync( + 'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && which node"', + { encoding: "utf-8" }, + ).trim(); + if (nvmPath && fs.existsSync(nvmPath)) { + return nvmPath; + } + } catch { + // Ignore errors + } + + // Fallback: look for node in common nvm location + const homeDir = process.env.HOME || process.env.USERPROFILE; + if (homeDir) { + const nvmVersionsDir = path.join(homeDir, ".nvm", "versions", "node"); + if (fs.existsSync(nvmVersionsDir)) { + const versions = fs.readdirSync(nvmVersionsDir).sort().reverse(); + for (const version of versions) { + const nodeBin = path.join(nvmVersionsDir, version, "bin", "node"); + if (fs.existsSync(nodeBin)) { + return nodeBin; + } + } + } + } + + return null; +} + module.exports = withTVXcodeEnv; From 44b7434cdd72294bf38ff5ca92bc3b691835ba9a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 17:28:15 +0100 Subject: [PATCH 162/309] refactor(tv): simplify user profile management with automatic sandboxing --- app.json | 1 + app/_layout.tsx | 1 - components/login/TVLogin.tsx | 28 ++++- .../tv-user-profile/expo-module.config.json | 8 ++ modules/tv-user-profile/index.ts | 103 ++++++++++++++++++ .../tv-user-profile/ios/TvUserProfile.podspec | 23 ++++ .../ios/TvUserProfileModule.swift | 103 ++++++++++++++++++ plugins/withTVUserManagement.js | 21 ++++ 8 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 modules/tv-user-profile/expo-module.config.json create mode 100644 modules/tv-user-profile/index.ts create mode 100644 modules/tv-user-profile/ios/TvUserProfile.podspec create mode 100644 modules/tv-user-profile/ios/TvUserProfileModule.swift create mode 100644 plugins/withTVUserManagement.js diff --git a/app.json b/app.json index 668c6163a..f9dfbb6da 100644 --- a/app.json +++ b/app.json @@ -76,6 +76,7 @@ "expo-router", "expo-font", "./plugins/withExcludeMedia3Dash.js", + "./plugins/withTVUserManagement.js", [ "expo-build-properties", { diff --git a/app/_layout.tsx b/app/_layout.tsx index 43fe21867..cd6b6de57 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -11,7 +11,6 @@ import * as Device from "expo-device"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; - import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 182d25d9d..0453ed9c2 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -390,12 +390,28 @@ export const TVLogin: React.FC = () => { pinCode?: string, ) => { setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); + + if (pendingLogin && currentServer) { + setLoading(true); + try { + await login(pendingLogin.username, pendingLogin.password, serverName, { + saveAccount: true, + securityType, + pinCode, + }); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } } }; diff --git a/modules/tv-user-profile/expo-module.config.json b/modules/tv-user-profile/expo-module.config.json new file mode 100644 index 000000000..6b34d7939 --- /dev/null +++ b/modules/tv-user-profile/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-user-profile", + "version": "1.0.0", + "platforms": ["apple"], + "apple": { + "modules": ["TvUserProfileModule"] + } +} diff --git a/modules/tv-user-profile/index.ts b/modules/tv-user-profile/index.ts new file mode 100644 index 000000000..a0672c729 --- /dev/null +++ b/modules/tv-user-profile/index.ts @@ -0,0 +1,103 @@ +import type { EventSubscription } from "expo-modules-core"; +import { Platform, requireNativeModule } from "expo-modules-core"; + +interface TvUserProfileModuleEvents { + onProfileChange: (event: { profileId: string | null }) => void; +} + +interface TvUserProfileModuleType { + getCurrentProfileId(): string | null; + isProfileSwitchingSupported(): boolean; + presentUserPicker(): Promise; + addListener( + eventName: K, + listener: TvUserProfileModuleEvents[K], + ): EventSubscription; +} + +// Only load the native module on Apple platforms +const TvUserProfileModule: TvUserProfileModuleType | null = + Platform.OS === "ios" + ? requireNativeModule("TvUserProfile") + : null; + +/** + * Get the current tvOS profile identifier. + * Returns null on non-tvOS platforms or if no profile is active. + */ +export function getCurrentProfileId(): string | null { + if (!TvUserProfileModule) { + return null; + } + + try { + return TvUserProfileModule.getCurrentProfileId() ?? null; + } catch (error) { + console.error("[TvUserProfile] Error getting profile ID:", error); + return null; + } +} + +/** + * Check if tvOS profile switching is supported on this device. + * Returns true only on tvOS. + */ +export function isProfileSwitchingSupported(): boolean { + if (!TvUserProfileModule) { + return false; + } + + try { + return TvUserProfileModule.isProfileSwitchingSupported(); + } catch (error) { + console.error("[TvUserProfile] Error checking profile support:", error); + return false; + } +} + +/** + * Subscribe to profile change events. + * The callback receives the new profile ID (or null if no profile). + * Returns an unsubscribe function. + */ +export function addProfileChangeListener( + callback: (profileId: string | null) => void, +): () => void { + if (!TvUserProfileModule) { + // Return no-op unsubscribe on unsupported platforms + return () => {}; + } + + const subscription = TvUserProfileModule.addListener( + "onProfileChange", + (event) => { + callback(event.profileId); + }, + ); + + return () => subscription.remove(); +} + +/** + * Present the system user picker panel. + * Returns true if successful, false otherwise. + */ +export async function presentUserPicker(): Promise { + if (!TvUserProfileModule) { + return false; + } + + try { + return await TvUserProfileModule.presentUserPicker(); + } catch (error) { + console.error("[TvUserProfile] Error presenting user picker:", error); + return false; + } +} + +export default { + getCurrentProfileId, + isProfileSwitchingSupported, + addProfileChangeListener, + presentUserPicker, +}; diff --git a/modules/tv-user-profile/ios/TvUserProfile.podspec b/modules/tv-user-profile/ios/TvUserProfile.podspec new file mode 100644 index 000000000..648af143c --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfile.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'TvUserProfile' + s.version = '1.0.0' + s.summary = 'tvOS User Profile Management for Expo' + s.description = 'Native tvOS module to get current user profile and listen for profile changes using TVUserManager' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '15.6', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # TVServices framework is only available on tvOS + s.tvos.frameworks = 'TVServices' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/tv-user-profile/ios/TvUserProfileModule.swift b/modules/tv-user-profile/ios/TvUserProfileModule.swift new file mode 100644 index 000000000..1885a8119 --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfileModule.swift @@ -0,0 +1,103 @@ +import ExpoModulesCore +#if os(tvOS) +import TVServices +#endif + +public class TvUserProfileModule: Module { + #if os(tvOS) + private let userManager = TVUserManager() + private var profileObservation: NSKeyValueObservation? + #endif + + public func definition() -> ModuleDefinition { + Name("TvUserProfile") + + // Define event that can be sent to JavaScript + Events("onProfileChange") + + // Get current tvOS profile identifier + Function("getCurrentProfileId") { () -> String? in + #if os(tvOS) + let identifier = self.userManager.currentUserIdentifier + print("[TvUserProfile] Current profile ID: \(identifier ?? "nil")") + return identifier + #else + return nil + #endif + } + + // Check if running on tvOS with profile support + Function("isProfileSwitchingSupported") { () -> Bool in + #if os(tvOS) + return true + #else + return false + #endif + } + + // Present the system user picker + AsyncFunction("presentUserPicker") { () -> Bool in + #if os(tvOS) + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.userManager.presentProfilePreferencePanel { error in + if let error = error { + print("[TvUserProfile] Error presenting user picker: \(error)") + continuation.resume(returning: false) + } else { + print("[TvUserProfile] User picker presented, new ID: \(self.userManager.currentUserIdentifier ?? "nil")") + continuation.resume(returning: true) + } + } + } + } + #else + return false + #endif + } + + OnCreate { + #if os(tvOS) + self.setupProfileObserver() + #endif + } + + OnDestroy { + #if os(tvOS) + self.profileObservation?.invalidate() + self.profileObservation = nil + #endif + } + } + + #if os(tvOS) + private func setupProfileObserver() { + // Debug: Print all available info about TVUserManager + print("[TvUserProfile] TVUserManager created") + print("[TvUserProfile] currentUserIdentifier: \(userManager.currentUserIdentifier ?? "nil")") + if #available(tvOS 16.0, *) { + print("[TvUserProfile] shouldStorePreferencesForCurrentUser: \(userManager.shouldStorePreferencesForCurrentUser)") + } + + // Set up KVO observation on currentUserIdentifier + profileObservation = userManager.observe(\.currentUserIdentifier, options: [.new, .old, .initial]) { [weak self] manager, change in + guard let self = self else { return } + + let newProfileId = change.newValue ?? nil + let oldProfileId = change.oldValue ?? nil + + print("[TvUserProfile] KVO fired - old: \(oldProfileId ?? "nil"), new: \(newProfileId ?? "nil")") + + // Only send event if the profile actually changed + if newProfileId != oldProfileId { + print("[TvUserProfile] Profile changed from \(oldProfileId ?? "nil") to \(newProfileId ?? "nil")") + self.sendEvent("onProfileChange", [ + "profileId": newProfileId as Any + ]) + } + } + + print("[TvUserProfile] Profile observer set up successfully") + } + #endif +} diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.js new file mode 100644 index 000000000..69d7880f4 --- /dev/null +++ b/plugins/withTVUserManagement.js @@ -0,0 +1,21 @@ +const { withEntitlementsPlist } = require("expo/config-plugins"); + +/** + * Expo config plugin to add User Management entitlement for tvOS profile linking + */ +const withTVUserManagement = (config) => { + return withEntitlementsPlist(config, (config) => { + // Only add for tvOS builds (check if building for TV) + // The entitlement is needed for TVUserManager.currentUserIdentifier to work + config.modResults["com.apple.developer.user-management"] = [ + "runs-as-current-user", + "get-current-user", + ]; + + console.log("[withTVUserManagement] Added user-management entitlement"); + + return config; + }); +}; + +module.exports = withTVUserManagement; From 591d89c19fe7b308fe963394139ac7043d032780 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 17:52:35 +0100 Subject: [PATCH 163/309] feat(tv): local build eas creds --- .gitignore | 2 ++ eas.json | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b8c7526ae..47fcc0cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ expo-env.d.ts pc-api-7079014811501811218-719-3b9f15aeccf8.json credentials.json streamyfin-4fec1-firebase-adminsdk.json +profiles/ +certs/ # Version and Backup Files /version-backup-* diff --git a/eas.json b/eas.json index 5ecd93c37..0a17c22a9 100644 --- a/eas.json +++ b/eas.json @@ -81,6 +81,9 @@ "channel": "0.52.0", "env": { "EXPO_TV": "1" + }, + "ios": { + "credentialsSource": "local" } } }, From 81cf672eb78dd804128d30552cfea42681964d1a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 18:54:23 +0100 Subject: [PATCH 164/309] fix: convert native.js to native.ts and fix imports for EAS builds - Convert utils/profiles/native.js to TypeScript - Add barrel export index.ts for profiles - Update all imports to use explicit file paths instead of barrel export - Fix .gitignore to only ignore root-level profiles/ directory --- .gitignore | 2 +- app/(auth)/player/direct-player.tsx | 2 +- components/PlayButton.tsx | 4 +-- providers/PlaySettingsProvider.tsx | 2 +- utils/jellyfin/audio/getAudioStreamUrl.ts | 2 +- utils/jellyfin/media/getDownloadUrl.ts | 2 +- utils/jellyfin/media/getStreamUrl.ts | 4 +-- utils/profiles/index.ts | 6 ++++ utils/profiles/native.d.ts | 23 ------------- utils/profiles/{native.js => native.ts} | 42 +++++++++++------------ 10 files changed, 35 insertions(+), 54 deletions(-) create mode 100644 utils/profiles/index.ts delete mode 100644 utils/profiles/native.d.ts rename utils/profiles/{native.js => native.ts} (83%) diff --git a/.gitignore b/.gitignore index 47fcc0cd2..e7f89813a 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,7 @@ expo-env.d.ts pc-api-7079014811501811218-719-3b9f15aeccf8.json credentials.json streamyfin-4fec1-firebase-adminsdk.json -profiles/ +/profiles/ certs/ # Version and Backup Files diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 5725c56cc..b9c1b8ab0 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -57,8 +57,8 @@ import { getMpvSubtitleId, } from "@/utils/jellyfin/subtitleUtils"; import { writeToLog } from "@/utils/log"; -import { generateDeviceProfile } from "@/utils/profiles/native"; import { msToTicks, ticksToSeconds } from "@/utils/time"; +import { generateDeviceProfile } from "../../../utils/profiles/native"; export default function page() { const videoRef = useRef(null); diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 1c3fd46f9..5b70ac165 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { runtimeTicksToMinutes } from "@/utils/time"; +import { chromecast } from "../utils/profiles/chromecast"; +import { chromecasth265 } from "../utils/profiles/chromecasth265"; import { Button } from "./Button"; import { Text } from "./common/Text"; import type { SelectedOptions } from "./ItemContent"; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index e3718a337..fe1d39f3b 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -9,7 +9,7 @@ import { Platform } from "react-native"; import type { Bitrate } from "@/components/BitrateSelector"; import { settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { generateDeviceProfile } from "@/utils/profiles/native"; +import { generateDeviceProfile } from "../utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; export type PlaybackType = { diff --git a/utils/jellyfin/audio/getAudioStreamUrl.ts b/utils/jellyfin/audio/getAudioStreamUrl.ts index df140d035..f8eb26292 100644 --- a/utils/jellyfin/audio/getAudioStreamUrl.ts +++ b/utils/jellyfin/audio/getAudioStreamUrl.ts @@ -1,7 +1,7 @@ import type { Api } from "@jellyfin/sdk"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import trackPlayerProfile from "@/utils/profiles/trackplayer"; +import trackPlayerProfile from "../../profiles/trackplayer"; export interface AudioStreamResult { url: string; diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts index 223c73c14..a63353b2b 100644 --- a/utils/jellyfin/media/getDownloadUrl.ts +++ b/utils/jellyfin/media/getDownloadUrl.ts @@ -7,7 +7,7 @@ import { Bitrate } from "@/components/BitrateSelector"; import { type AudioTranscodeModeType, generateDeviceProfile, -} from "@/utils/profiles/native"; +} from "../../profiles/native"; import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; export const getDownloadUrl = async ({ diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 355727184..8fe02df0c 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -5,8 +5,8 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import { generateDownloadProfile } from "@/utils/profiles/download"; -import type { AudioTranscodeModeType } from "@/utils/profiles/native"; +import { generateDownloadProfile } from "../../profiles/download"; +import type { AudioTranscodeModeType } from "../../profiles/native"; interface StreamResult { url: string; diff --git a/utils/profiles/index.ts b/utils/profiles/index.ts new file mode 100644 index 000000000..9ec48ada3 --- /dev/null +++ b/utils/profiles/index.ts @@ -0,0 +1,6 @@ +export { chromecast } from "./chromecast"; +export { chromecasth265 } from "./chromecasth265"; +export { generateDownloadProfile } from "./download"; +export * from "./native"; +export { default } from "./native"; +export { default as trackPlayerProfile } from "./trackplayer"; diff --git a/utils/profiles/native.d.ts b/utils/profiles/native.d.ts deleted file mode 100644 index 43489710e..000000000 --- a/utils/profiles/native.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -export type PlatformType = "ios" | "android"; -export type PlayerType = "mpv"; -export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; - -export interface ProfileOptions { - /** Target platform */ - platform?: PlatformType; - /** Video player being used */ - player?: PlayerType; - /** Audio transcoding mode */ - audioMode?: AudioTranscodeModeType; -} - -export function generateDeviceProfile(options?: ProfileOptions): any; - -declare const _default: any; -export default _default; diff --git a/utils/profiles/native.js b/utils/profiles/native.ts similarity index 83% rename from utils/profiles/native.js rename to utils/profiles/native.ts index ec74f4b60..9d7224ff9 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.ts @@ -7,22 +7,24 @@ import { Platform } from "react-native"; import MediaTypes from "../../constants/MediaTypes"; import { getSubtitleProfiles } from "./subtitles"; -/** - * @typedef {"ios" | "android"} PlatformType - * @typedef {"mpv"} PlayerType - * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType - * - * @typedef {Object} ProfileOptions - * @property {PlatformType} [platform] - Target platform - * @property {PlayerType} [player] - Video player being used (MPV only) - * @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode - */ +export type PlatformType = "ios" | "android"; +export type PlayerType = "mpv"; +export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; + +export interface ProfileOptions { + /** Target platform */ + platform?: PlatformType; + /** Video player being used */ + player?: PlayerType; + /** Audio transcoding mode */ + audioMode?: AudioTranscodeModeType; +} /** * Audio direct play profiles for standalone audio items in MPV player. * These define which audio file formats can be played directly without transcoding. */ -const getAudioDirectPlayProfile = (platform) => { +const getAudioDirectPlayProfile = (platform: PlatformType) => { if (platform === "ios") { // iOS audio formats supported by MPV return { @@ -44,7 +46,7 @@ const getAudioDirectPlayProfile = (platform) => { * Audio codec profiles for standalone audio items in MPV player. * These define codec constraints for audio file playback. */ -const getAudioCodecProfile = (platform) => { +const getAudioCodecProfile = (platform: PlatformType) => { if (platform === "ios") { // iOS audio codec constraints for MPV return { @@ -66,12 +68,11 @@ const getAudioCodecProfile = (platform) => { * MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA. * The audioMode setting only controls the maximum channel count - MPV will * decode and downmix as needed. - * - * @param {PlatformType} platform - * @param {AudioTranscodeModeType} audioMode - * @returns {{ directPlayCodec: string, maxAudioChannels: string }} */ -const getVideoAudioCodecs = (platform, audioMode) => { +const getVideoAudioCodecs = ( + platform: PlatformType, + audioMode: AudioTranscodeModeType, +): { directPlayCodec: string; maxAudioChannels: string } => { // Base codecs const baseCodecs = "aac,mp3,flac,opus,vorbis"; @@ -120,12 +121,9 @@ const getVideoAudioCodecs = (platform, audioMode) => { /** * Generates a device profile for Jellyfin playback. - * - * @param {ProfileOptions} [options] - Profile configuration options - * @returns {Object} Jellyfin device profile */ -export const generateDeviceProfile = (options = {}) => { - const platform = options.platform || Platform.OS; +export const generateDeviceProfile = (options: ProfileOptions = {}) => { + const platform = (options.platform || Platform.OS) as PlatformType; const audioMode = options.audioMode || "auto"; const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs( From 7e2962e5396afe91d65c768cec1f10e73d9d77eb Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 18:59:30 +0100 Subject: [PATCH 165/309] fix(tv): remove deprecated presentUserPicker API --- modules/tv-user-profile/index.ts | 19 ----------------- .../ios/TvUserProfileModule.swift | 21 ------------------- 2 files changed, 40 deletions(-) diff --git a/modules/tv-user-profile/index.ts b/modules/tv-user-profile/index.ts index a0672c729..b67897825 100644 --- a/modules/tv-user-profile/index.ts +++ b/modules/tv-user-profile/index.ts @@ -8,7 +8,6 @@ interface TvUserProfileModuleEvents { interface TvUserProfileModuleType { getCurrentProfileId(): string | null; isProfileSwitchingSupported(): boolean; - presentUserPicker(): Promise; addListener( eventName: K, listener: TvUserProfileModuleEvents[K], @@ -78,26 +77,8 @@ export function addProfileChangeListener( return () => subscription.remove(); } -/** - * Present the system user picker panel. - * Returns true if successful, false otherwise. - */ -export async function presentUserPicker(): Promise { - if (!TvUserProfileModule) { - return false; - } - - try { - return await TvUserProfileModule.presentUserPicker(); - } catch (error) { - console.error("[TvUserProfile] Error presenting user picker:", error); - return false; - } -} - export default { getCurrentProfileId, isProfileSwitchingSupported, addProfileChangeListener, - presentUserPicker, }; diff --git a/modules/tv-user-profile/ios/TvUserProfileModule.swift b/modules/tv-user-profile/ios/TvUserProfileModule.swift index 1885a8119..8a3d2a714 100644 --- a/modules/tv-user-profile/ios/TvUserProfileModule.swift +++ b/modules/tv-user-profile/ios/TvUserProfileModule.swift @@ -35,27 +35,6 @@ public class TvUserProfileModule: Module { #endif } - // Present the system user picker - AsyncFunction("presentUserPicker") { () -> Bool in - #if os(tvOS) - return await withCheckedContinuation { continuation in - DispatchQueue.main.async { - self.userManager.presentProfilePreferencePanel { error in - if let error = error { - print("[TvUserProfile] Error presenting user picker: \(error)") - continuation.resume(returning: false) - } else { - print("[TvUserProfile] User picker presented, new ID: \(self.userManager.currentUserIdentifier ?? "nil")") - continuation.resume(returning: true) - } - } - } - } - #else - return false - #endif - } - OnCreate { #if os(tvOS) self.setupProfileObserver() From dab1c10a039989d65d05134751528be6257edfb2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 19:55:22 +0100 Subject: [PATCH 166/309] fix(tv): use single value for user-management entitlement --- plugins/withTVUserManagement.js | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.js index 69d7880f4..0cb2f8e85 100644 --- a/plugins/withTVUserManagement.js +++ b/plugins/withTVUserManagement.js @@ -9,7 +9,6 @@ const withTVUserManagement = (config) => { // The entitlement is needed for TVUserManager.currentUserIdentifier to work config.modResults["com.apple.developer.user-management"] = [ "runs-as-current-user", - "get-current-user", ]; console.log("[withTVUserManagement] Added user-management entitlement"); From f549e8eaed48fd3d970dfa3a84e1da5d6e67845d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 21:03:54 +0100 Subject: [PATCH 167/309] feat(tv): reorder series page buttons to center season selector --- components/series/TVSeriesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 2dfe40441..83c1401a5 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -558,8 +558,6 @@ export const TVSeriesPage: React.FC = ({ - - {seasons.length > 1 && ( = ({ disabled={isSeasonModalVisible} /> )} + + From e6598f0944c601dfb562ea8d0d3e5de859904419 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 21:34:49 +0100 Subject: [PATCH 168/309] perf(tv): optimize focus animations and disable native glass effect --- .../InfiniteScrollingCollectionList.tv.tsx | 19 +------- components/home/TVHeroCarousel.tsx | 2 + components/tv/TVActorCard.tsx | 4 +- components/tv/TVPosterCard.tsx | 4 ++ components/tv/TVSeriesSeasonCard.tsx | 2 + .../ios/GlassPosterExpoView.swift | 47 ++++++++++--------- .../glass-poster/ios/GlassPosterView.swift | 30 ++++++++++-- modules/glass-poster/src/GlassPosterModule.ts | 23 +++++---- 8 files changed, 78 insertions(+), 53 deletions(-) diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 0b4e194ba..c3e1aa34c 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -6,7 +6,7 @@ import { useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -134,27 +134,16 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; - // Track focus within section for item focus/blur callbacks const flatListRef = useRef>(null); - const [_focusedCount, setFocusedCount] = useState(0); + // Pass through focus callbacks without tracking internal state const handleItemFocus = useCallback( (item: BaseItemDto) => { - setFocusedCount((c) => c + 1); onItemFocus?.(item); }, [onItemFocus], ); - const handleItemBlur = useCallback(() => { - setFocusedCount((c) => Math.max(0, c - 1)); - }, []); - - // Focus handler for See All card (doesn't need item parameter) - const handleSeeAllFocus = useCallback(() => { - setFocusedCount((c) => c + 1); - }, []); - const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: queryKey, @@ -234,7 +223,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} - onBlur={handleItemBlur} width={itemWidth} /> @@ -247,7 +235,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress, showItemActions, handleItemFocus, - handleItemBlur, ITEM_GAP, ], ); @@ -370,8 +357,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onPress={handleSeeAllPress} orientation={orientation} disabled={disabled} - onFocus={handleSeeAllFocus} - onBlur={handleItemBlur} typography={typography} posterSizes={posterSizes} /> diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 0c3189050..8118245bf 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -157,6 +157,8 @@ const HeroCard: React.FC = React.memo( borderRadius: 24, overflow: "hidden", transform: [{ scale }], + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", shadowColor: "#FFFFFF", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.6 : 0, diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index 29817512d..fba7ce1cd 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -56,8 +56,8 @@ export const TVActorCard = React.forwardRef( overflow: "hidden", backgroundColor: "rgba(255,255,255,0.1)", marginBottom: 14, - borderWidth: focused ? 3 : 0, - borderColor: "#fff", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > {imageUrl ? ( diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index c82598381..6cb4fa827 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -397,6 +397,8 @@ export const TVPosterCard: React.FC = ({ aspectRatio, borderRadius: 24, backgroundColor: "#1a1a1a", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} /> ); @@ -432,6 +434,8 @@ export const TVPosterCard: React.FC = ({ borderRadius: 24, overflow: "hidden", backgroundColor: "#1a1a1a", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > = ({ borderRadius: 24, overflow: "hidden", backgroundColor: "rgba(255,255,255,0.1)", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > {imageUrl ? ( diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift index 1c3341902..d2d654c58 100644 --- a/modules/glass-poster/ios/GlassPosterExpoView.swift +++ b/modules/glass-poster/ios/GlassPosterExpoView.swift @@ -1,11 +1,23 @@ import ExpoModulesCore import SwiftUI import UIKit +import Combine + +/// Observable state that SwiftUI can watch for changes without rebuilding the entire view +class GlassPosterState: ObservableObject { + @Published var imageUrl: String? = nil + @Published var aspectRatio: Double = 10.0 / 15.0 + @Published var cornerRadius: Double = 24 + @Published var progress: Double = 0 + @Published var showWatchedIndicator: Bool = false + @Published var isFocused: Bool = false + @Published var width: Double = 260 +} /// ExpoView wrapper that hosts the SwiftUI GlassPosterView class GlassPosterExpoView: ExpoView { - private var hostingController: UIHostingController? - private var posterView: GlassPosterView + private var hostingController: UIHostingController? + private let state = GlassPosterState() // Stored dimensions for intrinsic content size private var posterWidth: CGFloat = 260 @@ -16,13 +28,13 @@ class GlassPosterExpoView: ExpoView { let onError = EventDispatcher() required init(appContext: AppContext? = nil) { - self.posterView = GlassPosterView() super.init(appContext: appContext) setupHostingController() } private func setupHostingController() { - let hostingController = UIHostingController(rootView: posterView) + let wrapper = GlassPosterViewWrapper(state: state) + let hostingController = UIHostingController(rootView: wrapper) hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false @@ -38,10 +50,6 @@ class GlassPosterExpoView: ExpoView { self.hostingController = hostingController } - private func updateHostingController() { - hostingController?.rootView = posterView - } - // Override intrinsic content size for proper React Native layout override var intrinsicContentSize: CGSize { let height = posterWidth / posterAspectRatio @@ -49,43 +57,38 @@ class GlassPosterExpoView: ExpoView { } // MARK: - Property Setters + // These now update the observable state object directly. + // SwiftUI observes state changes and only re-renders affected views. func setImageUrl(_ url: String?) { - posterView.imageUrl = url - updateHostingController() + state.imageUrl = url } func setAspectRatio(_ ratio: Double) { - posterView.aspectRatio = ratio + state.aspectRatio = ratio posterAspectRatio = CGFloat(ratio) invalidateIntrinsicContentSize() - updateHostingController() } func setWidth(_ width: Double) { - posterView.width = width + state.width = width posterWidth = CGFloat(width) invalidateIntrinsicContentSize() - updateHostingController() } func setCornerRadius(_ radius: Double) { - posterView.cornerRadius = radius - updateHostingController() + state.cornerRadius = radius } func setProgress(_ progress: Double) { - posterView.progress = progress - updateHostingController() + state.progress = progress } func setShowWatchedIndicator(_ show: Bool) { - posterView.showWatchedIndicator = show - updateHostingController() + state.showWatchedIndicator = show } func setIsFocused(_ focused: Bool) { - posterView.isFocused = focused - updateHostingController() + state.isFocused = focused } } diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift index 3efa9d4c3..8c8e4f5f1 100644 --- a/modules/glass-poster/ios/GlassPosterView.swift +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -1,5 +1,24 @@ import SwiftUI +/// Wrapper view that observes state changes from GlassPosterState +/// This allows SwiftUI to efficiently update only the changed properties +/// instead of rebuilding the entire view hierarchy on every prop change. +struct GlassPosterViewWrapper: View { + @ObservedObject var state: GlassPosterState + + var body: some View { + GlassPosterView( + imageUrl: state.imageUrl, + aspectRatio: state.aspectRatio, + cornerRadius: state.cornerRadius, + progress: state.progress, + showWatchedIndicator: state.showWatchedIndicator, + isFocused: state.isFocused, + width: state.width + ) + } +} + /// SwiftUI view with tvOS 26 Liquid Glass effect struct GlassPosterView: View { var imageUrl: String? = nil @@ -35,7 +54,7 @@ struct GlassPosterView: View { #endif } - // MARK: - tvOS 26+ Glass Effect + // MARK: - tvOS 26+ Content (glass effect disabled for now) #if os(tvOS) @available(tvOS 26.0, *) @@ -45,6 +64,10 @@ struct GlassPosterView: View { imageContent .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0) + // Progress bar overlay if progress > 0 { progressOverlay @@ -56,7 +79,6 @@ struct GlassPosterView: View { } } .frame(width: width, height: height) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .focusable() .focused($isInternallyFocused) .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) @@ -72,9 +94,9 @@ struct GlassPosterView: View { imageContent .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - // Subtle overlay for depth + // White border on focus RoundedRectangle(cornerRadius: cornerRadius) - .fill(.ultraThinMaterial.opacity(0.15)) + .stroke(Color.white, lineWidth: isFocused ? 4 : 0) // Progress bar overlay if progress > 0 { diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts index 58ac4a258..20c2714f3 100644 --- a/modules/glass-poster/src/GlassPosterModule.ts +++ b/modules/glass-poster/src/GlassPosterModule.ts @@ -21,16 +21,23 @@ if (Platform.OS === "ios" && Platform.isTV) { /** * Check if the native glass effect is available (tvOS 26+) + * NOTE: Glass effect is currently disabled for performance reasons. + * The native module rebuilds views on every focus change which causes lag. + * Re-enable by uncommenting the native module check below. */ export function isGlassEffectAvailable(): boolean { - if (!GlassPosterNativeModule) { - return false; - } - try { - return GlassPosterNativeModule.isGlassEffectAvailable(); - } catch { - return false; - } + // Glass effect disabled - using JS-based focus effects instead + return false; + + // Original implementation (re-enable when glass effect is optimized): + // if (!GlassPosterNativeModule) { + // return false; + // } + // try { + // return GlassPosterNativeModule.isGlassEffectAvailable(); + // } catch { + // return false; + // } } export default GlassPosterNativeModule; From 3d406314a4e16e534a6bf97df92a8413b8b67c1c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 22:29:39 +0100 Subject: [PATCH 169/309] feat(tv): add configurable inactivity timeout with auto-logout --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 74 ++++++ app/_layout.tsx | 273 +++++++++++---------- components/tv/hooks/useTVFocusAnimation.ts | 5 +- providers/InactivityProvider.tsx | 186 ++++++++++++++ translations/en.json | 15 ++ utils/atoms/settings.ts | 16 ++ 6 files changed, 433 insertions(+), 136 deletions(-) create mode 100644 providers/InactivityProvider.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 9e5b794d3..7a32fc9c1 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -24,6 +24,7 @@ import { APP_LANGUAGES } from "@/i18n"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, + InactivityTimeout, type MpvCacheMode, TVTypographyScale, useSettings, @@ -300,6 +301,57 @@ export default function SettingsTV() { [t, currentLanguage], ); + // Inactivity timeout options (TV security feature) + const currentInactivityTimeout = + settings.inactivityTimeout ?? InactivityTimeout.Disabled; + + const inactivityTimeoutOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.security.inactivity_timeout.disabled"), + value: InactivityTimeout.Disabled, + selected: currentInactivityTimeout === InactivityTimeout.Disabled, + }, + { + label: t("home.settings.security.inactivity_timeout.1_minute"), + value: InactivityTimeout.OneMinute, + selected: currentInactivityTimeout === InactivityTimeout.OneMinute, + }, + { + label: t("home.settings.security.inactivity_timeout.5_minutes"), + value: InactivityTimeout.FiveMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.15_minutes"), + value: InactivityTimeout.FifteenMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.30_minutes"), + value: InactivityTimeout.ThirtyMinutes, + selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.1_hour"), + value: InactivityTimeout.OneHour, + selected: currentInactivityTimeout === InactivityTimeout.OneHour, + }, + { + label: t("home.settings.security.inactivity_timeout.4_hours"), + value: InactivityTimeout.FourHours, + selected: currentInactivityTimeout === InactivityTimeout.FourHours, + }, + { + label: t("home.settings.security.inactivity_timeout.24_hours"), + value: InactivityTimeout.TwentyFourHours, + selected: + currentInactivityTimeout === InactivityTimeout.TwentyFourHours, + }, + ], + [t, currentInactivityTimeout], + ); + // Get display labels for option buttons const audioTranscodeLabel = useMemo(() => { const option = audioTranscodeModeOptions.find((o) => o.selected); @@ -337,6 +389,13 @@ export default function SettingsTV() { return option?.label || t("home.settings.languages.system"); }, [currentLanguage, t]); + const inactivityTimeoutLabel = useMemo(() => { + const option = inactivityTimeoutOptions.find((o) => o.selected); + return ( + option?.label || t("home.settings.security.inactivity_timeout.disabled") + ); + }, [inactivityTimeoutOptions, t]); + return ( @@ -371,6 +430,21 @@ export default function SettingsTV() { isFirst /> + {/* Security Section */} + + + showOptions({ + title: t("home.settings.security.inactivity_timeout.title"), + options: inactivityTimeoutOptions, + onSelect: (value) => + updateSettings({ inactivityTimeout: value }), + }) + } + /> + {/* Audio Section */} - - - - - - - - - - - - - - - - - - - - - - + {!Platform.isTV && } + + + + + + + + + + + + ); diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts index b76d39f03..b3418c8cb 100644 --- a/components/tv/hooks/useTVFocusAnimation.ts +++ b/components/tv/hooks/useTVFocusAnimation.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { Animated, Easing } from "react-native"; +import { useInactivity } from "@/providers/InactivityProvider"; export interface UseTVFocusAnimationOptions { scaleAmount?: number; @@ -24,6 +25,7 @@ export const useTVFocusAnimation = ({ }: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; + const { resetInactivityTimer } = useInactivity(); const animateTo = useCallback( (value: number) => { @@ -40,8 +42,9 @@ export const useTVFocusAnimation = ({ const handleFocus = useCallback(() => { setFocused(true); animateTo(scaleAmount); + resetInactivityTimer(); onFocus?.(); - }, [animateTo, scaleAmount, onFocus]); + }, [animateTo, scaleAmount, resetInactivityTimer, onFocus]); const handleBlur = useCallback(() => { setFocused(false); diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx new file mode 100644 index 000000000..d76a55f7d --- /dev/null +++ b/providers/InactivityProvider.tsx @@ -0,0 +1,186 @@ +import type React from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { AppState, type AppStateStatus, Platform } from "react-native"; +import { useJellyfin } from "@/providers/JellyfinProvider"; +import { InactivityTimeout, useSettings } from "@/utils/atoms/settings"; +import { storage } from "@/utils/mmkv"; + +const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY"; + +interface InactivityContextValue { + resetInactivityTimer: () => void; +} + +const InactivityContext = createContext( + undefined, +); + +/** + * TV-only provider that tracks user inactivity and auto-logs out + * when the configured timeout is exceeded. + * + * Features: + * - Tracks last activity timestamp (persisted to MMKV) + * - Resets timer on any focus change (via resetInactivityTimer) + * - Handles app backgrounding: logs out immediately if timeout exceeded while away + * - No-op on mobile platforms + */ +export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const { settings } = useSettings(); + const { logout } = useJellyfin(); + const timerRef = useRef(null); + const appStateRef = useRef(AppState.currentState); + + const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled; + const isEnabled = Platform.isTV && timeoutMs > 0; + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const updateLastActivity = useCallback(() => { + if (!isEnabled) return; + storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now()); + }, [isEnabled]); + + const getLastActivity = useCallback((): number => { + return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now(); + }, []); + + const startTimer = useCallback( + (remainingMs?: number) => { + if (!isEnabled) return; + + clearTimer(); + + const delay = remainingMs ?? timeoutMs; + timerRef.current = setTimeout(() => { + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + }, delay); + }, + [isEnabled, timeoutMs, clearTimer, logout], + ); + + const resetInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + // Handle app state changes (background/foreground) + useEffect(() => { + if (!isEnabled) return; + + const handleAppStateChange = (nextAppState: AppStateStatus) => { + const wasBackground = + appStateRef.current === "background" || + appStateRef.current === "inactive"; + const isNowActive = nextAppState === "active"; + + if (wasBackground && isNowActive) { + // App returned to foreground - check if timeout exceeded + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Timeout exceeded while backgrounded - logout immediately + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Restart timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + } else if (nextAppState === "background" || nextAppState === "inactive") { + // App going to background - clear timer (time continues via timestamp) + clearTimer(); + } + + appStateRef.current = nextAppState; + }; + + const subscription = AppState.addEventListener( + "change", + handleAppStateChange, + ); + + return () => { + subscription.remove(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Initialize timer when enabled or timeout changes + useEffect(() => { + if (!isEnabled) { + clearTimer(); + return; + } + + // Check if we should logout based on last activity + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Already timed out - logout + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Start timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + + return () => { + clearTimer(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Reset activity on initial mount when enabled + useEffect(() => { + if (isEnabled) { + updateLastActivity(); + startTimer(); + } + }, []); + + const contextValue: InactivityContextValue = { + resetInactivityTimer, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to access the inactivity reset function. + * Returns a no-op function if not within the provider (safe on mobile). + */ +export const useInactivity = (): InactivityContextValue => { + const context = useContext(InactivityContext); + + // Return a no-op if not within provider (e.g., on mobile) + if (!context) { + return { + resetInactivityTimer: () => {}, + }; + } + + return context; +}; diff --git a/translations/en.json b/translations/en.json index bd25ba87b..49cdc180e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -481,6 +481,21 @@ "error_deleting_files": "Error Deleting Files", "background_downloads_enabled": "Background downloads enabled", "background_downloads_disabled": "Background downloads disabled" + }, + "security": { + "title": "Security", + "inactivity_timeout": { + "title": "Inactivity Timeout", + "description": "Auto logout after inactivity", + "disabled": "Disabled", + "1_minute": "1 minute", + "5_minutes": "5 minutes", + "15_minutes": "15 minutes", + "30_minutes": "30 minutes", + "1_hour": "1 hour", + "4_hours": "4 hours", + "24_hours": "24 hours" + } } }, "sessions": { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 63b2ee16d..31540ccb6 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -154,6 +154,18 @@ export enum AudioTranscodeMode { AllowAll = "passthrough", // Direct play all audio formats } +// Inactivity timeout for TV - auto logout after period of no activity +export enum InactivityTimeout { + Disabled = 0, + OneMinute = 60000, + FiveMinutes = 300000, + FifteenMinutes = 900000, + ThirtyMinutes = 1800000, + OneHour = 3600000, + FourHours = 14400000, + TwentyFourHours = 86400000, +} + // MPV cache mode - controls how caching is enabled export type MpvCacheMode = "auto" | "yes" | "no"; @@ -234,6 +246,8 @@ export type Settings = { audioTranscodeMode: AudioTranscodeMode; // OpenSubtitles API key for client-side subtitle fetching openSubtitlesApiKey?: string; + // TV-only: Inactivity timeout for auto-logout + inactivityTimeout: InactivityTimeout; }; export interface Lockable { @@ -329,6 +343,8 @@ export const defaultValues: Settings = { preferLocalAudio: true, // Audio transcoding mode audioTranscodeMode: AudioTranscodeMode.Auto, + // TV-only: Inactivity timeout (disabled by default) + inactivityTimeout: InactivityTimeout.Disabled, }; const loadSettings = (): Partial => { From ad1d9b5888214dab09dcfa3d07844685cdecc031 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 23:33:11 +0100 Subject: [PATCH 170/309] fix(tv): pause inactivity timer during video playback --- app/(auth)/player/direct-player.tsx | 21 +++++++++++-- providers/InactivityProvider.tsx | 47 +++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b9c1b8ab0..2f40a2d2a 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -45,8 +45,8 @@ import { } from "@/modules"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; +import { useInactivity } from "@/providers/InactivityProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; - import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -105,6 +105,9 @@ export default function page() { // when data updates, only when the provider initializes const downloadedFiles = downloadUtils.getDownloadedItems(); + // Inactivity timer controls (TV only) + const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity(); + const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const lightHapticFeedback = useHaptic("light"); @@ -421,7 +424,9 @@ export default function page() { setIsPlaybackStopped(true); videoRef.current?.pause(); revalidateProgressCache(); - }, [videoRef, reportPlaybackStopped, progress]); + // Resume inactivity timer when leaving player (TV only) + resumeInactivityTimer(); + }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); @@ -729,6 +734,8 @@ export default function page() { setIsPlaying(true); setIsBuffering(false); setHasPlaybackStarted(true); + // Pause inactivity timer during playback (TV only) + pauseInactivityTimer(); if (item?.Id) { playbackManager.reportPlaybackProgress( currentPlayStateInfo() as PlaybackProgressInfo, @@ -740,6 +747,8 @@ export default function page() { if (isPaused) { setIsPlaying(false); + // Resume inactivity timer when paused (TV only) + resumeInactivityTimer(); if (item?.Id) { playbackManager.reportPlaybackProgress( currentPlayStateInfo() as PlaybackProgressInfo, @@ -753,7 +762,13 @@ export default function page() { setIsBuffering(isLoading); } }, - [playbackManager, item?.Id, progress], + [ + playbackManager, + item?.Id, + progress, + pauseInactivityTimer, + resumeInactivityTimer, + ], ); /** PiP handler for MPV */ diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx index d76a55f7d..2c47ada6f 100644 --- a/providers/InactivityProvider.tsx +++ b/providers/InactivityProvider.tsx @@ -16,6 +16,8 @@ const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY"; interface InactivityContextValue { resetInactivityTimer: () => void; + pauseInactivityTimer: () => void; + resumeInactivityTimer: () => void; } const InactivityContext = createContext( @@ -29,6 +31,7 @@ const InactivityContext = createContext( * Features: * - Tracks last activity timestamp (persisted to MMKV) * - Resets timer on any focus change (via resetInactivityTimer) + * - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer) * - Handles app backgrounding: logs out immediately if timeout exceeded while away * - No-op on mobile platforms */ @@ -39,6 +42,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const { logout } = useJellyfin(); const timerRef = useRef(null); const appStateRef = useRef(AppState.currentState); + const isPausedRef = useRef(false); const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled; const isEnabled = Platform.isTV && timeoutMs > 0; @@ -61,7 +65,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const startTimer = useCallback( (remainingMs?: number) => { - if (!isEnabled) return; + if (!isEnabled || isPausedRef.current) return; clearTimer(); @@ -75,8 +79,25 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ ); const resetInactivityTimer = useCallback(() => { + if (!isEnabled || isPausedRef.current) return; + + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + const pauseInactivityTimer = useCallback(() => { if (!isEnabled) return; + isPausedRef.current = true; + clearTimer(); + // Update last activity so when we resume, we start fresh + updateLastActivity(); + }, [isEnabled, clearTimer, updateLastActivity]); + + const resumeInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + isPausedRef.current = false; updateLastActivity(); startTimer(); }, [isEnabled, updateLastActivity, startTimer]); @@ -92,7 +113,14 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const isNowActive = nextAppState === "active"; if (wasBackground && isNowActive) { - // App returned to foreground - check if timeout exceeded + // App returned to foreground + // If paused (e.g., video playing), don't check timeout + if (isPausedRef.current) { + appStateRef.current = nextAppState; + return; + } + + // Check if timeout exceeded const lastActivity = getLastActivity(); const elapsed = Date.now() - lastActivity; @@ -130,6 +158,9 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ return; } + // Don't start timer if paused + if (isPausedRef.current) return; + // Check if we should logout based on last activity const lastActivity = getLastActivity(); const elapsed = Date.now() - lastActivity; @@ -151,7 +182,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ // Reset activity on initial mount when enabled useEffect(() => { - if (isEnabled) { + if (isEnabled && !isPausedRef.current) { updateLastActivity(); startTimer(); } @@ -159,6 +190,8 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const contextValue: InactivityContextValue = { resetInactivityTimer, + pauseInactivityTimer, + resumeInactivityTimer, }; return ( @@ -169,16 +202,18 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ }; /** - * Hook to access the inactivity reset function. - * Returns a no-op function if not within the provider (safe on mobile). + * Hook to access the inactivity timer controls. + * Returns no-op functions if not within the provider (safe on mobile). */ export const useInactivity = (): InactivityContextValue => { const context = useContext(InactivityContext); - // Return a no-op if not within provider (e.g., on mobile) + // Return no-ops if not within provider (e.g., on mobile) if (!context) { return { resetInactivityTimer: () => {}, + pauseInactivityTimer: () => {}, + resumeInactivityTimer: () => {}, }; } From fea3e1449ac7e093ffbc59efd97e41d6e2b713b1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 23:43:05 +0100 Subject: [PATCH 171/309] fix(player): add null check for api in direct-player --- app/(auth)/player/direct-player.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 2f40a2d2a..49fc16b6b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -231,7 +231,12 @@ export default function page() { setDownloadedItem(data); } } else { - const res = await getUserLibraryApi(api!).getItem({ + // Guard against api being null (e.g., during logout) + if (!api) { + setItemStatus({ isLoading: false, isError: false }); + return; + } + const res = await getUserLibraryApi(api).getItem({ itemId, userId: user?.Id, }); From d17414bc939833734512200244d69baffb5fd8b5 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 12:27:22 +0100 Subject: [PATCH 172/309] fix(auth): distinguish session expiry from network errors --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 42 ++++++++++++++++++---- components/PreviousServersList.tsx | 28 +++++++++++---- components/login/Login.tsx | 18 +++++----- components/login/TVLogin.tsx | 44 +++++++++++++++++------- providers/JellyfinProvider.tsx | 33 ++++++++++++++---- 5 files changed, 124 insertions(+), 41 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 7a32fc9c1..9dd566736 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -2,7 +2,7 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { Alert, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; @@ -80,12 +80,26 @@ export default function SettingsTV() { const hasOtherAccounts = otherAccounts.length > 0; // Handle account selection from modal - const handleAccountSelect = (account: SavedServerAccount) => { + const handleAccountSelect = async (account: SavedServerAccount) => { if (!currentServer) return; if (account.securityType === "none") { // Direct login with saved credential - loginWithSavedCredential(currentServer.address, account.userId); + try { + await loginWithSavedCredential(currentServer.address, account.userId); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + ); + } } else if (account.securityType === "pin") { // Show PIN modal setSelectedServer(currentServer); @@ -103,10 +117,24 @@ export default function SettingsTV() { const handlePinSuccess = async () => { setPinModalVisible(false); if (selectedServer && selectedAccount) { - await loginWithSavedCredential( - selectedServer.address, - selectedAccount.userId, - ); + try { + await loginWithSavedCredential( + selectedServer.address, + selectedAccount.userId, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + ); + } } setSelectedServer(null); setSelectedAccount(null); diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx index 008e1be2a..251a6ca3d 100644 --- a/components/PreviousServersList.tsx +++ b/components/PreviousServersList.tsx @@ -73,10 +73,19 @@ export const PreviousServersList: React.FC = ({ setLoadingServer(server.address); try { await onQuickLogin(server.address, account.userId); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], ); } finally { @@ -122,10 +131,17 @@ export const PreviousServersList: React.FC = ({ setLoadingServer(selectedServer.address); try { await onQuickLogin(selectedServer.address, selectedAccount.userId); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, [ { text: t("common.ok"), diff --git a/components/login/Login.tsx b/components/login/Login.tsx index 0b1fedc7d..7cf5f43fc 100644 --- a/components/login/Login.tsx +++ b/components/login/Login.tsx @@ -72,22 +72,24 @@ export const Login: React.FC = () => { password: string; } | null>(null); + // Handle URL params for server connection useEffect(() => { (async () => { if (_apiUrl) { await setServer({ address: _apiUrl, }); - - setTimeout(() => { - if (_username && _password) { - setCredentials({ username: _username, password: _password }); - login(_username, _password); - } - }, 0); } })(); - }, [_apiUrl, _username, _password]); + }, [_apiUrl]); + + // Handle auto-login when api is ready and credentials are provided via URL params + useEffect(() => { + if (api?.basePath && _apiUrl && _username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, [api?.basePath, _apiUrl, _username, _password]); useEffect(() => { navigation.setOptions({ diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 0453ed9c2..2ddfac786 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -122,19 +122,21 @@ export const TVLogin: React.FC = () => { }; }, [stopQuickConnectPolling]); - // Auto login from URL params + // Handle URL params for server connection useEffect(() => { (async () => { if (_apiUrl) { await setServer({ address: _apiUrl }); - setTimeout(() => { - if (_username && _password) { - login(_username, _password); - } - }, 0); } })(); - }, [_apiUrl, _username, _password]); + }, [_apiUrl]); + + // Handle auto-login when api is ready and credentials are provided via URL params + useEffect(() => { + if (api?.basePath && _apiUrl && _username && _password) { + login(_username, _password); + } + }, [api?.basePath, _apiUrl, _username, _password]); // Update header useEffect(() => { @@ -263,10 +265,19 @@ export const TVLogin: React.FC = () => { setLoading(true); try { await loginWithSavedCredential(currentServer.address, account.userId); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, [ { text: t("common.ok"), @@ -301,10 +312,17 @@ export const TVLogin: React.FC = () => { currentServer.address, selectedAccount.userId, ); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, ); } finally { setLoading(false); diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 1b066ba3c..ebdaaca19 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -427,14 +427,33 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ // Refresh plugin settings await refreshStreamyfinPluginSettings(); } catch (error) { - // Token is invalid/expired - remove it - if ( - axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403) - ) { - await deleteAccountCredential(serverUrl, userId); - throw new Error(t("server.session_expired")); + // Check for axios error + if (axios.isAxiosError(error)) { + // Token is invalid/expired - remove it + if ( + error.response?.status === 401 || + error.response?.status === 403 + ) { + await deleteAccountCredential(serverUrl, userId); + throw new Error(t("server.session_expired")); + } + + // Network error - server not reachable (no response means server didn't respond) + if (!error.response) { + throw new Error(t("home.server_unreachable")); + } } + + // Check for network error by message pattern (fallback detection) + if ( + error instanceof Error && + (error.message.toLowerCase().includes("network") || + error.message.toLowerCase().includes("econnrefused") || + error.message.toLowerCase().includes("timeout")) + ) { + throw new Error(t("home.server_unreachable")); + } + throw error; } }, From 25ec9c4348e300ed370995876ff093c5662b8103 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 12:39:05 +0100 Subject: [PATCH 173/309] fix(tv): remove automatic scroll triggers on series page --- components/series/TVSeriesPage.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 83c1401a5..ce179c0dd 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -256,20 +256,12 @@ export const TVSeriesPage: React.FC = ({ useEffect(() => { if (prevFocusedCount.current > 0 && focusedCount === 0) { episodeListRef.current?.scrollTo({ x: 0, animated: true }); - // Scroll page back to top when leaving episode section - mainScrollRef.current?.scrollTo({ y: 0, animated: true }); } prevFocusedCount.current = focusedCount; }, [focusedCount]); const handleEpisodeFocus = useCallback(() => { - setFocusedCount((c) => { - // Scroll page down when first episode receives focus - if (c === 0) { - mainScrollRef.current?.scrollTo({ y: 200, animated: true }); - } - return c + 1; - }); + setFocusedCount((c) => c + 1); }, []); const handleEpisodeBlur = useCallback(() => { From 4962f2161fa78cf3ce15a8d72f98356ff43f6df8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 12:44:36 +0100 Subject: [PATCH 174/309] refactor(tv): remove auto-scroll behaviors from search and series --- components/search/TVSearchSection.tsx | 5 +---- components/series/TVSeriesPage.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index d1101892f..ec4f8199b 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -41,11 +41,8 @@ export const TVSearchSection: React.FC = ({ const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); - // When section loses all focus, scroll back to start + // Track focus count for section useEffect(() => { - if (prevFocusedCount.current > 0 && focusedCount === 0) { - flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); - } prevFocusedCount.current = focusedCount; }, [focusedCount]); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index ce179c0dd..3d904e952 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -252,11 +252,8 @@ export const TVSeriesPage: React.FC = ({ const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); - // Scroll back to start when episode list loses focus + // Track focus count for episode list useEffect(() => { - if (prevFocusedCount.current > 0 && focusedCount === 0) { - episodeListRef.current?.scrollTo({ x: 0, animated: true }); - } prevFocusedCount.current = focusedCount; }, [focusedCount]); From 2775075187272f1d533bd7c6d14b70f7716d259a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 13:03:47 +0100 Subject: [PATCH 175/309] docs: add settings atom and translation key guidelines --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 357616b00..eb2ae87e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error **State Management**: - Global state uses Jotai atoms in `utils/atoms/` - `settingsAtom` in `utils/atoms/settings.ts` for app settings + - **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope) - `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state - Server state uses React Query with `@tanstack/react-query` @@ -158,6 +159,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - Handle both mobile and TV navigation patterns - Use existing atoms, hooks, and utilities before creating new ones - Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):` +- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case. ## Platform Considerations From fb7cee77185246f79add479aac3e830dc05e0762 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 14:03:20 +0100 Subject: [PATCH 176/309] fix(tv): improve skip/countdown focus and back button handling --- app/(auth)/player/direct-player.tsx | 116 +++++++++++----- components/tv/TVNextEpisodeCountdown.tsx | 5 +- components/tv/TVSkipSegmentCard.tsx | 30 +--- .../video-player/controls/Controls.tv.tsx | 129 +++++------------- .../controls/hooks/useRemoteControl.ts | 81 +++++++++-- 5 files changed, 199 insertions(+), 162 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 49fc16b6b..f210ed95c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -43,6 +43,10 @@ import { type MpvPlayerViewRef, type MpvVideoSource, } from "@/modules"; +import { + isNativeTVControlsAvailable, + TVPlayerControlsView, +} from "@/modules/tv-player-controls"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { useInactivity } from "@/providers/InactivityProvider"; @@ -1189,37 +1193,87 @@ export default function page() { item && !isPipMode && (Platform.isTV ? ( - + // TV Controls: Use native SwiftUI controls if enabled and available, otherwise JS controls + settings.useNativeTVControls && + isNativeTVControlsAvailable() ? ( + seek(e.nativeEvent.positionMs)} + onSkipForward={() => { + const newPos = Math.min( + (item.RunTimeTicks ?? 0) / 10000, + progress.value + 30000, + ); + progress.value = newPos; + seek(newPos); + }} + onSkipBackward={() => { + const newPos = Math.max(0, progress.value - 10000); + progress.value = newPos; + seek(newPos); + }} + // Audio/subtitle settings will be handled in future iteration + // These would need the same modal hooks as the JS controls + onBack={() => router.back()} + onVisibilityChange={(e) => + setShowControls(e.nativeEvent.visible) + } + /> + ) : ( + + ) ) : ( void; /** Called when user presses the card to skip to next episode */ onPlayNext?: () => void; - /** Whether this card should capture focus when visible */ - hasFocus?: boolean; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; } @@ -48,7 +46,6 @@ export const TVNextEpisodeCountdown: FC = ({ isPlaying, onFinish, onPlayNext, - hasFocus = false, controlsVisible = false, }) => { const typography = useScaledTVTypography(); @@ -141,7 +138,7 @@ export const TVNextEpisodeCountdown: FC = ({ onPress={onPlayNext} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={hasFocus} + hasTVPreferredFocus={true} focusable={true} > diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index f735e7d52..3e53d0f01 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -1,12 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; -import { type FC, useEffect, useRef } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Pressable, - Animated as RNAnimated, - StyleSheet, - View, -} from "react-native"; +import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; import Animated, { Easing, useAnimatedStyle, @@ -20,8 +16,6 @@ export interface TVSkipSegmentCardProps { show: boolean; onPress: () => void; type: "intro" | "credits"; - /** Whether this card should capture focus when visible */ - hasFocus?: boolean; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; } @@ -34,30 +28,15 @@ export const TVSkipSegmentCard: FC = ({ show, onPress, type, - hasFocus = false, controlsVisible = false, }) => { const { t } = useTranslation(); - const pressableRef = useRef(null); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.1, duration: 120, }); - // Programmatically request focus when card appears with hasFocus=true - useEffect(() => { - if (!show || !hasFocus || !pressableRef.current) return; - - const timer = setTimeout(() => { - // Use setNativeProps to trigger focus update on tvOS - (pressableRef.current as any)?.setNativeProps?.({ - hasTVPreferredFocus: true, - }); - }, 50); - return () => clearTimeout(timer); - }, [show, hasFocus]); - // Animated position based on controls visibility const bottomPosition = useSharedValue( controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, @@ -88,11 +67,10 @@ export const TVSkipSegmentCard: FC = ({ pointerEvents='box-none' > = ({ max.value, ); - // Countdown logic - needs to be early so toggleControls can reference it + // Countdown logic const isCountdownActive = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); - // Whether any skip card is visible - used to prevent focus conflicts - const isSkipCardVisible = - (showSkipButton && !isCountdownActive) || - (showSkipCreditButton && + // Simple boolean - when skip cards or countdown are visible, they have focus + const isSkipOrCountdownVisible = useMemo(() => { + const skipIntroVisible = showSkipButton && !isCountdownActive; + const skipCreditsVisible = + showSkipCreditButton && (hasContentAfterCredits || !nextItem) && - !isCountdownActive); - - // Brief delay to ignore focus events when countdown first appears - const countdownJustActivatedRef = useRef(false); - - useEffect(() => { - if (!isCountdownActive) { - countdownJustActivatedRef.current = false; - return; - } - countdownJustActivatedRef.current = true; - const timeout = setTimeout(() => { - countdownJustActivatedRef.current = false; - }, 200); - return () => clearTimeout(timeout); - }, [isCountdownActive]); - - // Brief delay to ignore focus events when skip card first appears - const skipCardJustActivatedRef = useRef(false); - - useEffect(() => { - if (!isSkipCardVisible) { - skipCardJustActivatedRef.current = false; - return; - } - skipCardJustActivatedRef.current = true; - const timeout = setTimeout(() => { - skipCardJustActivatedRef.current = false; - }, 200); - return () => clearTimeout(timeout); - }, [isSkipCardVisible]); - - // Brief delay to ignore focus events after pressing skip button - const skipJustPressedRef = useRef(false); - - // Wrapper to prevent focus events after skip actions - const handleSkipWithDelay = useCallback((skipFn: () => void) => { - skipJustPressedRef.current = true; - skipFn(); - setTimeout(() => { - skipJustPressedRef.current = false; - }, 500); - }, []); - - const handleSkipIntro = useCallback(() => { - handleSkipWithDelay(skipIntro); - }, [handleSkipWithDelay, skipIntro]); - - const handleSkipCredit = useCallback(() => { - handleSkipWithDelay(skipCredit); - }, [handleSkipWithDelay, skipCredit]); + !isCountdownActive; + return skipIntroVisible || skipCreditsVisible || isCountdownActive; + }, [ + showSkipButton, + showSkipCreditButton, + hasContentAfterCredits, + nextItem, + isCountdownActive, + ]); // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; @@ -507,14 +466,9 @@ export const Controls: FC = ({ }; const toggleControls = useCallback(() => { - // Skip if countdown or skip card just became active (ignore initial focus event) - const shouldIgnore = - countdownJustActivatedRef.current || - skipCardJustActivatedRef.current || - skipJustPressedRef.current; - if (shouldIgnore) return; + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle setShowControls(!showControls); - }, [showControls, setShowControls]); + }, [showControls, setShowControls, isSkipOrCountdownVisible]); const [showSeekBubble, setShowSeekBubble] = useState(false); const [seekBubbleTime, setSeekBubbleTime] = useState({ @@ -942,18 +896,22 @@ export const Controls: FC = ({ // Callback for up/down D-pad - show controls with play button focused const handleVerticalDpad = useCallback(() => { - // Skip if countdown or skip card just became active (ignore initial focus event) - const shouldIgnore = - countdownJustActivatedRef.current || - skipCardJustActivatedRef.current || - skipJustPressedRef.current; - if (shouldIgnore) return; + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls setFocusPlayButton(true); setShowControls(true); + }, [setShowControls, isSkipOrCountdownVisible]); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); }, [setShowControls]); + const handleBack = useCallback(() => { + router.back(); + }, [router]); + const { isSliding: isRemoteSliding } = useRemoteControl({ - showControls, + showControls: showControls, toggleControls, togglePlay, isProgressBarFocused, @@ -966,15 +924,13 @@ export const Controls: FC = ({ onLongSeekRightStart: handleDpadLongSeekForward, onLongSeekStop: stopContinuousSeeking, onVerticalDpad: handleVerticalDpad, + onHideControls: hideControls, + onBack: handleBack, + videoTitle: item?.Name ?? undefined, }); - const hideControls = useCallback(() => { - setShowControls(false); - setFocusPlayButton(false); - }, [setShowControls]); - const { handleControlsInteraction } = useControlsTimeout({ - showControls, + showControls: showControls, isSliding: isRemoteSliding, episodeView: false, onHideControls: hideControls, @@ -1081,9 +1037,8 @@ export const Controls: FC = ({ {/* Skip intro card */} @@ -1094,14 +1049,8 @@ export const Controls: FC = ({ (hasContentAfterCredits || !nextItem) && !isCountdownActive } - onPress={handleSkipCredit} + onPress={skipCredit} type='credits' - hasFocus={ - showSkipCreditButton && - (hasContentAfterCredits || !nextItem) && - !isCountdownActive && - !showSkipButton - } controlsVisible={showControls} /> @@ -1113,7 +1062,6 @@ export const Controls: FC = ({ isPlaying={isPlaying} onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} - hasFocus={isCountdownActive} controlsVisible={showControls} /> )} @@ -1215,7 +1163,7 @@ export const Controls: FC = ({ = ({ onFocus={() => setIsProgressBarFocused(true)} onBlur={() => setIsProgressBarFocused(false)} refSetter={setProgressBarRef} - hasTVPreferredFocus={ - !isCountdownActive && - !isSkipCardVisible && - lastOpenedModal === null && - !focusPlayButton - } + hasTVPreferredFocus={false} /> diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index f30fda23a..513c8dd78 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Platform } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { Alert, BackHandler, Platform } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; // TV event handler with fallback for non-TV platforms @@ -23,6 +23,10 @@ interface UseRemoteControlProps { disableSeeking?: boolean; /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ onBack?: () => void; + /** Callback to hide controls (called on back press when controls are visible) */ + onHideControls?: () => void; + /** Title of the video being played (shown in exit confirmation) */ + videoTitle?: string; /** Whether the progress bar currently has focus */ isProgressBarFocused?: boolean; /** Callback for seeking left when progress bar is focused */ @@ -69,6 +73,8 @@ export function useRemoteControl({ toggleControls, togglePlay, onBack, + onHideControls, + videoTitle, isProgressBarFocused, onSeekLeft, onSeekRight, @@ -87,14 +93,73 @@ export function useRemoteControl({ const [isSliding] = useState(false); const [time] = useState({ hours: 0, minutes: 0, seconds: 0 }); + // Use refs to avoid stale closures in BackHandler + const showControlsRef = useRef(showControls); + const onHideControlsRef = useRef(onHideControls); + const onBackRef = useRef(onBack); + const videoTitleRef = useRef(videoTitle); + + useEffect(() => { + showControlsRef.current = showControls; + onHideControlsRef.current = onHideControls; + onBackRef.current = onBack; + videoTitleRef.current = videoTitle; + }, [showControls, onHideControls, onBack, videoTitle]); + + // Handle hardware back button (works on both Android TV and tvOS) + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible - just hide them + onHideControlsRef.current(); + return true; // Prevent default back navigation + } + if (onBackRef.current) { + // Controls are hidden - show confirmation before exiting + Alert.alert( + "Stop Playback", + videoTitleRef.current + ? `Stop playing "${videoTitleRef.current}"?` + : "Are you sure you want to stop playback?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop", style: "destructive", onPress: onBackRef.current }, + ], + ); + return true; // Prevent default back navigation + } + return false; // Let default back navigation happen + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, []); + // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - // Handle back/menu button press (tvOS: menu, Android TV: back) - if (evt.eventType === "menu" || evt.eventType === "back") { - if (onBack) { - onBack(); + // Back/menu is handled by BackHandler above, but keep this for tvOS menu button + if (evt.eventType === "menu") { + if (showControls && onHideControls) { + onHideControls(); + } else if (onBack) { + Alert.alert( + "Stop Playback", + videoTitle + ? `Stop playing "${videoTitle}"?` + : "Are you sure you want to stop playback?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop", style: "destructive", onPress: onBack }, + ], + ); } return; } @@ -154,8 +219,8 @@ export function useRemoteControl({ onVerticalDpad(); return; } - // For other D-pad presses, show full controls - toggleControls(); + // Ignore all other events (focus/blur, swipes, etc.) + // User can press up/down to show controls return; } From 2bcf52209eac1347d78f40fd9df43857650ac2e7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 14:38:35 +0100 Subject: [PATCH 177/309] refactor(tv): remove native tv-player-controls module usage --- app/(auth)/player/direct-player.tsx | 116 ++++++++-------------------- 1 file changed, 31 insertions(+), 85 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index f210ed95c..49fc16b6b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -43,10 +43,6 @@ import { type MpvPlayerViewRef, type MpvVideoSource, } from "@/modules"; -import { - isNativeTVControlsAvailable, - TVPlayerControlsView, -} from "@/modules/tv-player-controls"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { useInactivity } from "@/providers/InactivityProvider"; @@ -1193,87 +1189,37 @@ export default function page() { item && !isPipMode && (Platform.isTV ? ( - // TV Controls: Use native SwiftUI controls if enabled and available, otherwise JS controls - settings.useNativeTVControls && - isNativeTVControlsAvailable() ? ( - seek(e.nativeEvent.positionMs)} - onSkipForward={() => { - const newPos = Math.min( - (item.RunTimeTicks ?? 0) / 10000, - progress.value + 30000, - ); - progress.value = newPos; - seek(newPos); - }} - onSkipBackward={() => { - const newPos = Math.max(0, progress.value - 10000); - progress.value = newPos; - seek(newPos); - }} - // Audio/subtitle settings will be handled in future iteration - // These would need the same modal hooks as the JS controls - onBack={() => router.back()} - onVisibilityChange={(e) => - setShowControls(e.nativeEvent.visible) - } - /> - ) : ( - - ) + ) : ( Date: Sun, 1 Feb 2026 15:52:26 +0100 Subject: [PATCH 178/309] fix: no log --- app/_layout.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/_layout.tsx b/app/_layout.tsx index ca2498918..7dd8abf7e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -63,8 +63,18 @@ import useRouter from "@/hooks/useAppRouter"; import { userAtom } from "@/providers/JellyfinProvider"; import { store as jotaiStore, store } from "@/utils/store"; import "react-native-reanimated"; +import { + configureReanimatedLogger, + ReanimatedLogLevel, +} from "react-native-reanimated"; import { Toaster } from "sonner-native"; +// Disable strict mode warnings for reading shared values during render +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, +}); + if (!Platform.isTV) { Notifications.setNotificationHandler({ handleNotification: async () => ({ From a384b344022b86a3c1db81d8db43cd086239097d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 15:52:54 +0100 Subject: [PATCH 179/309] chore: translations --- translations/en.json | 6 +++++- translations/sv.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/translations/en.json b/translations/en.json index 49cdc180e..b4c634208 100644 --- a/translations/en.json +++ b/translations/en.json @@ -568,6 +568,7 @@ "none": "None", "track": "Track", "cancel": "Cancel", + "stop": "Stop", "delete": "Delete", "ok": "OK", "remove": "Remove", @@ -701,7 +702,10 @@ "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback", "settings": "Settings", "skip_intro": "Skip Intro", - "skip_credits": "Skip Credits" + "skip_credits": "Skip Credits", + "stopPlayback": "Stop Playback", + "stopPlayingTitle": "Stop playing \"{{title}}\"?", + "stopPlayingConfirm": "Are you sure you want to stop playback?" }, "item_card": { "next_up": "Next Up", diff --git a/translations/sv.json b/translations/sv.json index 975549315..f356a528e 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -540,6 +540,7 @@ "none": "Ingen", "track": "Spår", "cancel": "Avbryt", + "stop": "Stoppa", "delete": "Ta bort", "ok": "OK", "remove": "Radera", @@ -674,7 +675,10 @@ "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv", "settings": "Inställningar", "skip_intro": "Hoppa över intro", - "skip_credits": "Hoppa över eftertexter" + "skip_credits": "Hoppa över eftertexter", + "stopPlayback": "Stoppa uppspelning", + "stopPlayingTitle": "Sluta spela \"{{title}}\"?", + "stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?" }, "item_card": { "next_up": "Näst på tur", From 7d0b3be8c2c30c7c1823995ac88cbf6bf54334a2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 16:27:32 +0100 Subject: [PATCH 180/309] chore(tv): remove debug logs from back handler --- hooks/useTVBackHandler.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts index 9c7b7c4ef..dd5ca1062 100644 --- a/hooks/useTVBackHandler.ts +++ b/hooks/useTVBackHandler.ts @@ -79,14 +79,12 @@ export function useTVBackHandler() { if (isOnHomeRoot) { // On home tab root - disable interception to allow app exit if (lastMenuKeyState.current !== false) { - console.log("[useTVBackHandler] On home root - enabling app exit"); TVEventControl.disableTVMenuKey(); lastMenuKeyState.current = false; } } else { // On other screens - enable interception to handle navigation if (lastMenuKeyState.current !== true) { - console.log("[useTVBackHandler] Not on home - intercepting menu key"); TVEventControl.enableTVMenuKey(); lastMenuKeyState.current = true; } @@ -98,33 +96,23 @@ export function useTVBackHandler() { if (!evt) return; if (evt.eventType === "menu" || evt.eventType === "back") { // If on home root, let the default behavior happen (app exit) - // This shouldn't fire since we disabled menu key interception if (isOnHomeRoot) { - console.log("[useTVBackHandler] On home root, allowing exit"); return; } - console.log("[useTVBackHandler] Menu pressed:", { - currentTab, - atTabRoot, - }); - - // If at tab root level (but not home) + // If at tab root level (but not home), navigate to home if (atTabRoot) { - console.log("[useTVBackHandler] At tab root, navigating to home"); router.navigate("/(auth)/(tabs)/(home)"); return; } // Not at tab root - go back in the stack if (navigation.canGoBack()) { - console.log("[useTVBackHandler] Going back in navigation stack"); navigation.goBack(); return; } // Fallback: navigate to home - console.log("[useTVBackHandler] Fallback: navigating to home"); router.navigate("/(auth)/(tabs)/(home)"); } }); @@ -167,9 +155,6 @@ export function useTVBackHandler() { */ export function enableTVMenuKeyInterception() { if (Platform.isTV && TVEventControl) { - console.log( - "[enableTVMenuKeyInterception] Enabling TV menu key interception", - ); TVEventControl.enableTVMenuKey(); } } From ab526f2c6bb77f9eaf32ec8bf4e96f736405b853 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 16:39:04 +0100 Subject: [PATCH 181/309] chore(tv): suppress tvOS hover gesture warning --- app/_layout.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 7dd8abf7e..ac900896c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -56,9 +56,15 @@ import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { I18nextProvider } from "react-i18next"; -import { Appearance } from "react-native"; +import { Appearance, LogBox } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; + +// Suppress harmless tvOS warning from react-native-gesture-handler +if (Platform.isTV) { + LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]); +} + import useRouter from "@/hooks/useAppRouter"; import { userAtom } from "@/providers/JellyfinProvider"; import { store as jotaiStore, store } from "@/utils/store"; From bc575c26c1ce8fe69caadc2a55f7258c13f5ee89 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 17:29:31 +0100 Subject: [PATCH 182/309] feat(mpv): add opaque subtitle background with adjustable opacity (iOS only) --- components/settings/MpvSubtitleSettings.tsx | 121 +++++++++++------- modules/mpv-player/ios/MPVLayerRenderer.swift | 20 ++- modules/mpv-player/ios/MpvPlayerModule.swift | 14 +- modules/mpv-player/ios/MpvPlayerView.swift | 12 ++ modules/mpv-player/src/MpvPlayer.types.ts | 5 + modules/mpv-player/src/MpvPlayerView.tsx | 11 ++ translations/en.json | 3 +- translations/sv.json | 3 +- utils/atoms/settings.ts | 4 + 9 files changed, 142 insertions(+), 51 deletions(-) diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx index c715cbe7b..4ef2a8001 100644 --- a/components/settings/MpvSubtitleSettings.tsx +++ b/components/settings/MpvSubtitleSettings.tsx @@ -1,6 +1,6 @@ import { Ionicons } from "@expo/vector-icons"; import { useMemo } from "react"; -import { Platform, View, type ViewProps } from "react-native"; +import { Platform, Switch, View, type ViewProps } from "react-native"; import { Stepper } from "@/components/inputs/Stepper"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; @@ -55,7 +55,6 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => { return [{ options }]; }, [settings?.mpvSubtitleAlignY, updateSettings]); - if (isTv) return null; if (!settings) return null; return ( @@ -68,53 +67,83 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => { } > - - updateSettings({ mpvSubtitleMarginY: value })} + {!isTv && ( + <> + + + updateSettings({ mpvSubtitleMarginY: value }) + } + /> + + + + + + {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} + + + + } + title='Horizontal Alignment' + /> + + + + + + {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} + + + + } + title='Vertical Alignment' + /> + + + )} + + + + updateSettings({ mpvSubtitleBackgroundEnabled: value }) + } /> - - - - {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} - - - - } - title='Horizontal Alignment' - /> - - - - - - {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} - - - - } - title='Vertical Alignment' - /> - + {settings.mpvSubtitleBackgroundEnabled && ( + + + updateSettings({ mpvSubtitleBackgroundOpacity: value }) + } + /> + + )} ); diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 52b3afec3..4d0f9577b 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -195,7 +195,8 @@ final class MPVLayerRenderer { // CRITICAL: This option MUST be set immediately after vo=avfoundation, before hwdec options. // On tvOS, moving this elsewhere causes the app to freeze when exiting the player. // - iOS: "yes" for PiP subtitle support (subtitles baked into video) - // - tvOS: "no" to prevent gray tint + frame drops with subtitles + // - tvOS: "no" - composite OSD breaks subtitle rendering entirely on tvOS + // Note: This means subtitle styling (background colors) won't work on tvOS #if os(tvOS) checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no")) #else @@ -820,7 +821,22 @@ final class MPVLayerRenderer { func setSubtitleFontSize(_ size: Int) { setProperty(name: "sub-font-size", value: String(size)) } - + + func setSubtitleBackgroundColor(_ color: String) { + setProperty(name: "sub-back-color", value: color) + } + + func setSubtitleBorderStyle(_ style: String) { + // "outline-and-shadow" (default) or "background-box" (enables background color) + setProperty(name: "sub-border-style", value: style) + } + + func setSubtitleAssOverride(_ mode: String) { + // Controls whether to override ASS subtitle styles + // "no" = keep ASS styles, "force" = override with user settings + setProperty(name: "sub-ass-override", value: mode) + } + // MARK: - Audio Track Controls func getAudioTracks() -> [[String: Any]] { diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index c85c7fa3b..08665e287 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -157,7 +157,19 @@ public class MpvPlayerModule: Module { AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in view.setSubtitleFontSize(size) } - + + AsyncFunction("setSubtitleBackgroundColor") { (view: MpvPlayerView, color: String) in + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleBorderStyle") { (view: MpvPlayerView, style: String) in + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleAssOverride") { (view: MpvPlayerView, mode: String) in + view.setSubtitleAssOverride(mode) + } + // Audio track functions AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in return view.getAudioTracks() diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index a06558033..5fdf5a971 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -319,6 +319,18 @@ class MpvPlayerView: ExpoView { renderer?.setSubtitleFontSize(size) } + func setSubtitleBackgroundColor(_ color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + func setSubtitleBorderStyle(_ style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + func setSubtitleAssOverride(_ mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + // MARK: - Video Scaling func setZoomedToFill(_ zoomed: Bool) { diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index c700cb820..552cbefad 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -95,6 +95,11 @@ export interface MpvPlayerViewRef { setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise; setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise; setSubtitleFontSize: (size: number) => Promise; + setSubtitleBackgroundColor: (color: string) => Promise; + setSubtitleBorderStyle: ( + style: "outline-and-shadow" | "background-box", + ) => Promise; + setSubtitleAssOverride: (mode: "no" | "force") => Promise; // Audio controls getAudioTracks: () => Promise; setAudioTrack: (trackId: number) => Promise; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index ad3fcdfa4..cec13b0ff 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -84,6 +84,17 @@ export default React.forwardRef( setSubtitleFontSize: async (size: number) => { await nativeRef.current?.setSubtitleFontSize(size); }, + setSubtitleBackgroundColor: async (color: string) => { + await nativeRef.current?.setSubtitleBackgroundColor(color); + }, + setSubtitleBorderStyle: async ( + style: "outline-and-shadow" | "background-box", + ) => { + await nativeRef.current?.setSubtitleBorderStyle(style); + }, + setSubtitleAssOverride: async (mode: "no" | "force") => { + await nativeRef.current?.setSubtitleAssOverride(mode); + }, // Audio controls getAudioTracks: async () => { return await nativeRef.current?.getAudioTracks(); diff --git a/translations/en.json b/translations/en.json index b4c634208..e53522959 100644 --- a/translations/en.json +++ b/translations/en.json @@ -705,7 +705,8 @@ "skip_credits": "Skip Credits", "stopPlayback": "Stop Playback", "stopPlayingTitle": "Stop playing \"{{title}}\"?", - "stopPlayingConfirm": "Are you sure you want to stop playback?" + "stopPlayingConfirm": "Are you sure you want to stop playback?", + "downloaded": "Downloaded" }, "item_card": { "next_up": "Next Up", diff --git a/translations/sv.json b/translations/sv.json index f356a528e..2c95fe776 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -678,7 +678,8 @@ "skip_credits": "Hoppa över eftertexter", "stopPlayback": "Stoppa uppspelning", "stopPlayingTitle": "Sluta spela \"{{title}}\"?", - "stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?" + "stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?", + "downloaded": "Nedladdad" }, "item_card": { "next_up": "Näst på tur", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 31540ccb6..f7dbf7911 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -214,6 +214,8 @@ export type Settings = { mpvSubtitleAlignX?: "left" | "center" | "right"; mpvSubtitleAlignY?: "top" | "center" | "bottom"; mpvSubtitleFontSize?: number; + mpvSubtitleBackgroundEnabled?: boolean; + mpvSubtitleBackgroundOpacity?: number; // 0-100 // MPV buffer/cache settings mpvCacheEnabled?: MpvCacheMode; mpvCacheSeconds?: number; @@ -313,6 +315,8 @@ export const defaultValues: Settings = { mpvSubtitleAlignX: undefined, mpvSubtitleAlignY: undefined, mpvSubtitleFontSize: undefined, + mpvSubtitleBackgroundEnabled: false, + mpvSubtitleBackgroundOpacity: 75, // MPV buffer/cache defaults mpvCacheEnabled: "auto", mpvCacheSeconds: 10, From c35e97f38862cf1d2f74d007c6d06fe74ac8239f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 19:19:32 +0100 Subject: [PATCH 183/309] feat(tv): persist downloaded opensubtitles across app restarts --- app/(auth)/player/direct-player.tsx | 46 +++++ app/(auth)/tv-subtitle-modal.tsx | 30 +++- components/ItemContent.tv.tsx | 96 ++++++++++- .../controls/contexts/VideoContext.tsx | 37 +++- components/video-player/controls/types.ts | 4 + hooks/useRemoteSubtitles.ts | 64 ++++++- utils/atoms/downloadedSubtitles.ts | 162 ++++++++++++++++++ utils/opensubtitles/api.ts | 107 +++++++----- 8 files changed, 489 insertions(+), 57 deletions(-) create mode 100644 utils/atoms/downloadedSubtitles.ts diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 49fc16b6b..7caeba24c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -10,6 +10,7 @@ import { getPlaystateApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; +import { File } from "expo-file-system"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtomValue } from "jotai"; @@ -49,6 +50,7 @@ import { useInactivity } from "@/providers/InactivityProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; @@ -1075,6 +1077,28 @@ export default function page() { if (settings.mpvSubtitleAlignY !== undefined) { await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); } + // Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation) + // mpv uses #RRGGBBAA format (alpha last, same as CSS) + if (settings.mpvSubtitleBackgroundEnabled) { + const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75; + const alphaHex = Math.round((opacity / 100) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + // Enable background-box mode (required for sub-back-color to work) + await videoRef.current?.setSubtitleBorderStyle?.("background-box"); + await videoRef.current?.setSubtitleBackgroundColor?.( + `#000000${alphaHex}`, + ); + // Force override ASS subtitle styles so background shows on styled subtitles + await videoRef.current?.setSubtitleAssOverride?.("force"); + } else { + // Restore default outline-and-shadow style + await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow"); + await videoRef.current?.setSubtitleBackgroundColor?.("#00000000"); + // Restore default ASS behavior (keep original styles) + await videoRef.current?.setSubtitleAssOverride?.("no"); + } }; applySubtitleSettings(); @@ -1094,6 +1118,28 @@ export default function page() { applyInitialPlaybackSpeed(); }, [isVideoLoaded, initialPlaybackSpeed]); + // TV only: Pre-load locally downloaded subtitles when video loads + // This adds them to MPV's track list without auto-selecting them + useEffect(() => { + if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId) + return; + + const preloadLocalSubtitles = async () => { + const localSubs = getSubtitlesForItem(itemId); + for (const sub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(sub.filePath); + if (!subtitleFile.exists) { + continue; + } + // Add subtitle file to MPV without selecting it (select: false) + await videoRef.current?.addSubtitleFile?.(sub.filePath, false); + } + }; + + preloadLocalSubtitles(); + }, [isVideoLoaded, itemId]); + // Show error UI first, before checking loading/missing‐data if (itemStatus.isError || streamStatus.isError) { return ( diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 1745beed7..e597a7823 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -659,8 +659,30 @@ export default function TVSubtitleModal() { // Do NOT close modal - user can see and select the new track } else if (downloadResult.type === "local" && downloadResult.path) { + // Notify parent that a local subtitle was downloaded modalState?.onLocalSubtitleDownloaded?.(downloadResult.path); - handleClose(); // Only close for local downloads + + // Check if component is still mounted after callback + if (!isMountedRef.current) return; + + // Refresh tracks to include the newly downloaded subtitle + if (modalState?.refreshSubtitleTracks) { + const newTracks = await modalState.refreshSubtitleTracks(); + + // Check if component is still mounted after fetching tracks + if (!isMountedRef.current) return; + + // Update atom with new tracks + store.set(tvSubtitleModalAtom, { + ...modalState, + subtitleTracks: newTracks, + }); + // Switch to tracks tab to show the new subtitle + setActiveTab("tracks"); + } else { + // No refreshSubtitleTracks available (e.g., from player), just close + handleClose(); + } } } catch (error) { console.error("Failed to download subtitle:", error); @@ -685,13 +707,17 @@ export default function TVSubtitleModal() { value: -1, selected: currentSubtitleIndex === -1, setTrack: () => modalState?.onDisableSubtitles?.(), + isLocal: false, }; const options = subtitleTracks.map((track: Track) => ({ label: track.name, - sublabel: undefined as string | undefined, + sublabel: track.isLocal + ? t("player.downloaded") || "Downloaded" + : (undefined as string | undefined), value: track.index, selected: track.index === currentSubtitleIndex, setTrack: track.setTrack, + isLocal: track.isLocal ?? false, })); return [noneOption, ...options]; }, [subtitleTracks, currentSubtitleIndex, t, modalState]); diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index f9e4578b4..02498d373 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -7,6 +7,7 @@ import type { import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; +import { File } from "expo-file-system"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import React, { @@ -50,6 +51,7 @@ import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { useTVThemeMusic } from "@/hooks/useTVThemeMusic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; @@ -243,9 +245,16 @@ export const ItemContentTV: React.FC = React.memo( null, ); + // State to trigger refresh of local subtitles list + const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0); + + // Starting index for local (client-downloaded) subtitles + const LOCAL_SUBTITLE_INDEX_START = -100; + // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) + // Also includes locally downloaded subtitles from OpenSubtitles const subtitleTracksForModal = useMemo((): Track[] => { - return subtitleStreams.map((stream) => ({ + const tracks: Track[] = subtitleStreams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, @@ -254,7 +263,37 @@ export const ItemContentTV: React.FC = React.memo( handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); - }, [subtitleStreams]); + + // Add locally downloaded subtitles (from OpenSubtitles) + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // For ItemContent (outside player), just update the selected index + // The actual subtitle will be loaded when playback starts + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]); // Get available media sources const mediaSources = useMemo(() => { @@ -346,6 +385,12 @@ export const ItemContentTV: React.FC = React.memo( } }, [item?.Id, queryClient]); + // Handle local subtitle download - trigger refresh of subtitle tracks + const handleLocalSubtitleDownloaded = useCallback((_path: string) => { + // Increment the refresh key to trigger re-computation of subtitleTracksForModal + setLocalSubtitlesRefreshKey((prev) => prev + 1); + }, []); + // Refresh subtitle tracks by fetching fresh item data from Jellyfin const refreshSubtitleTracks = useCallback(async (): Promise => { if (!api || !item?.Id) return []; @@ -373,7 +418,7 @@ export const ItemContentTV: React.FC = React.memo( ) ?? []; // Convert to Track[] with setTrack callbacks - return streams.map((stream) => ({ + const tracks: Track[] = streams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, @@ -382,6 +427,30 @@ export const ItemContentTV: React.FC = React.memo( handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); + + // Add locally downloaded subtitles + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) continue; + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; } catch (error) { console.error("Failed to refresh subtitle tracks:", error); return []; @@ -399,13 +468,30 @@ export const ItemContentTV: React.FC = React.memo( const selectedSubtitleLabel = useMemo(() => { if (selectedOptions?.subtitleIndex === -1) return t("item_card.subtitles.none"); + + // Check if it's a local subtitle (negative index starting at -100) + if ( + selectedOptions?.subtitleIndex !== undefined && + selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START + ) { + const localTrack = subtitleTracksForModal.find( + (t) => t.index === selectedOptions.subtitleIndex, + ); + return localTrack?.name || t("item_card.subtitles.label"); + } + const track = subtitleStreams.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); return ( track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") ); - }, [subtitleStreams, selectedOptions?.subtitleIndex, t]); + }, [ + subtitleStreams, + subtitleTracksForModal, + selectedOptions?.subtitleIndex, + t, + ]); const selectedMediaSourceLabel = useMemo(() => { const source = selectedOptions?.mediaSource; @@ -742,6 +828,8 @@ export const ItemContentTV: React.FC = React.memo( onDisableSubtitles: () => handleSubtitleChange(-1), onServerSubtitleDownloaded: handleServerSubtitleDownloaded, + onLocalSubtitleDownloaded: + handleLocalSubtitleDownloaded, refreshSubtitleTracks, }) } diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ec9ca995f..7c5750849 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -47,6 +47,7 @@ */ import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; +import { File } from "expo-file-system"; import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { @@ -57,13 +58,19 @@ import { useMemo, useState, } from "react"; +import { Platform } from "react-native"; import useRouter from "@/hooks/useAppRouter"; import type { MpvAudioTrack } from "@/modules"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import type { Track } from "../types"; import { usePlayerContext, usePlayerControls } from "./PlayerContext"; +// Starting index for local (client-downloaded) subtitles +// Uses negative indices to avoid collision with Jellyfin indices +const LOCAL_SUBTITLE_INDEX_START = -100; + interface VideoContextProps { subtitleTracks: Track[] | null; audioTracks: Track[] | null; @@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ }; }); + // TV only: Merge locally downloaded subtitles (from OpenSubtitles) + if (Platform.isTV && itemId) { + const localSubs = getSubtitlesForItem(itemId); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + subs.push({ + name: localSub.name, + index: localIndex, + mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // Add the subtitle file to MPV and select it + playerControls.addSubtitleFile(localSub.filePath, true); + router.setParams({ subtitleIndex: String(localIndex) }); + }, + }); + localIdx++; + } + } + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); setAudioTracks(audio); }; fetchTracks(); - }, [tracksReady, mediaSource, offline, downloadedItem]); + }, [tracksReady, mediaSource, offline, downloadedItem, itemId]); return ( diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index 5ec03eddb..30f277aa4 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -22,6 +22,10 @@ type Track = { index: number; mpvIndex?: number; setTrack: () => void; + /** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */ + isLocal?: boolean; + /** File path for local subtitles */ + localPath?: string; }; export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts index bc5b83e7a..b101aeeee 100644 --- a/hooks/useRemoteSubtitles.ts +++ b/hooks/useRemoteSubtitles.ts @@ -7,7 +7,12 @@ import { useMutation } from "@tanstack/react-query"; import { Directory, File, Paths } from "expo-file-system"; import { useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { + addDownloadedSubtitle, + type DownloadedSubtitle, +} from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import { OpenSubtitlesApi, @@ -185,32 +190,70 @@ export function useRemoteSubtitles({ /** * Download subtitle via OpenSubtitles API (returns local file path) + * + * On TV: Downloads to cache directory and persists metadata in MMKV + * On mobile: Downloads to cache directory (ephemeral, no persistence) + * + * Uses a flat filename structure with itemId prefix to avoid tvOS permission issues */ const downloadOpenSubtitles = useCallback( - async (fileId: number): Promise => { + async ( + fileId: number, + result: SubtitleSearchResult, + ): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => { if (!openSubtitlesApi) { throw new Error("OpenSubtitles API key not configured"); } // Get download link const response = await openSubtitlesApi.download(fileId); + const originalFileName = response.file_name || `subtitle_${fileId}.srt`; - // Download to cache directory - const fileName = response.file_name || `subtitle_${fileId}.srt`; - const subtitlesDir = new Directory(Paths.cache, "subtitles"); + // Use cache directory for both platforms (tvOS has permission issues with documents) + // TV: Uses itemId prefix for organization and persists metadata + // Mobile: Simple filename, no persistence + const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles"); // Ensure directory exists if (!subtitlesDir.exists) { - subtitlesDir.create({ intermediates: true }); + subtitlesDir.create(); } + // TV: Prefix filename with itemId for organization + // Mobile: Use original filename + const fileName = Platform.isTV + ? `${itemId}_${originalFileName}` + : originalFileName; + // Create file and download const destination = new File(subtitlesDir, fileName); + + // Delete existing file if it exists (re-download) + if (destination.exists) { + destination.delete(); + } + await File.downloadFileAsync(response.link, destination); - return destination.uri; + // TV: Persist metadata for future sessions + if (Platform.isTV) { + const subtitleMetadata: DownloadedSubtitle = { + id: result.id, + itemId, + filePath: destination.uri, + name: result.name, + language: result.language, + format: result.format, + source: "opensubtitles", + downloadedAt: Date.now(), + }; + addDownloadedSubtitle(subtitleMetadata); + return { path: destination.uri, subtitle: subtitleMetadata }; + } + + return { path: destination.uri }; }, - [openSubtitlesApi], + [openSubtitlesApi, itemId], ); /** @@ -257,8 +300,11 @@ export function useRemoteSubtitles({ return { type: "server" as const }; } if (result.fileId) { - const localPath = await downloadOpenSubtitles(result.fileId); - return { type: "local" as const, path: localPath }; + const { path, subtitle } = await downloadOpenSubtitles( + result.fileId, + result, + ); + return { type: "local" as const, path, subtitle }; } throw new Error("Invalid subtitle result"); }, diff --git a/utils/atoms/downloadedSubtitles.ts b/utils/atoms/downloadedSubtitles.ts new file mode 100644 index 000000000..69d17a9f8 --- /dev/null +++ b/utils/atoms/downloadedSubtitles.ts @@ -0,0 +1,162 @@ +/** + * Downloaded Subtitles Storage + * + * Persists metadata about client-side downloaded subtitles (from OpenSubtitles). + * Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory. + * Filenames are prefixed with itemId for organization: {itemId}_{filename} + * + * While files are in cache, metadata is persisted in MMKV so subtitles survive + * app restarts (unless cache is manually cleared by the user). + * + * TV platform only. + */ + +import { storage } from "../mmkv"; + +// MMKV storage key +const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json"; + +/** + * Metadata for a downloaded subtitle file + */ +export interface DownloadedSubtitle { + /** Unique identifier (uuid) */ + id: string; + /** Jellyfin item ID */ + itemId: string; + /** Local file path in documents directory */ + filePath: string; + /** Display name */ + name: string; + /** 3-letter language code */ + language: string; + /** File format (srt, ass, etc.) */ + format: string; + /** Source provider */ + source: "opensubtitles"; + /** Unix timestamp when downloaded */ + downloadedAt: number; +} + +/** + * Storage structure for downloaded subtitles + */ +interface DownloadedSubtitlesStorage { + /** Map of itemId to array of downloaded subtitles */ + byItemId: Record; +} + +/** + * Load the storage from MMKV + */ +function loadStorage(): DownloadedSubtitlesStorage { + try { + const data = storage.getString(DOWNLOADED_SUBTITLES_KEY); + if (data) { + return JSON.parse(data) as DownloadedSubtitlesStorage; + } + } catch { + // Ignore parse errors, return empty storage + } + return { byItemId: {} }; +} + +/** + * Save the storage to MMKV + */ +function saveStorage(data: DownloadedSubtitlesStorage): void { + try { + storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data)); + } catch (error) { + console.error("Failed to save downloaded subtitles:", error); + } +} + +/** + * Get all downloaded subtitles for a specific Jellyfin item + */ +export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] { + const data = loadStorage(); + return data.byItemId[itemId] ?? []; +} + +/** + * Add a downloaded subtitle to storage + */ +export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void { + const data = loadStorage(); + + // Initialize array for item if it doesn't exist + if (!data.byItemId[subtitle.itemId]) { + data.byItemId[subtitle.itemId] = []; + } + + // Check if subtitle with same id already exists and update it + const existingIndex = data.byItemId[subtitle.itemId].findIndex( + (s) => s.id === subtitle.id, + ); + + if (existingIndex !== -1) { + // Update existing entry + data.byItemId[subtitle.itemId][existingIndex] = subtitle; + } else { + // Add new entry + data.byItemId[subtitle.itemId].push(subtitle); + } + + saveStorage(data); +} + +/** + * Remove a downloaded subtitle from storage + */ +export function removeDownloadedSubtitle( + itemId: string, + subtitleId: string, +): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + data.byItemId[itemId] = data.byItemId[itemId].filter( + (s) => s.id !== subtitleId, + ); + + // Clean up empty arrays + if (data.byItemId[itemId].length === 0) { + delete data.byItemId[itemId]; + } + + saveStorage(data); + } +} + +/** + * Remove all downloaded subtitles for a specific item + */ +export function removeAllSubtitlesForItem(itemId: string): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + delete data.byItemId[itemId]; + saveStorage(data); + } +} + +/** + * Check if a subtitle file already exists for an item by language + */ +export function hasSubtitleForLanguage( + itemId: string, + language: string, +): boolean { + const subtitles = getSubtitlesForItem(itemId); + return subtitles.some((s) => s.language === language); +} + +/** + * Get all downloaded subtitles across all items + */ +export function getAllDownloadedSubtitles(): DownloadedSubtitle[] { + const data = loadStorage(); + return Object.values(data.byItemId).flat(); +} diff --git a/utils/opensubtitles/api.ts b/utils/opensubtitles/api.ts index d9101cf80..230591985 100644 --- a/utils/opensubtitles/api.ts +++ b/utils/opensubtitles/api.ts @@ -87,6 +87,58 @@ export class OpenSubtitlesApiError extends Error { } } +/** + * Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes + */ +const ISO_639_MAPPING: Record = { + en: "eng", + es: "spa", + fr: "fre", + de: "ger", + it: "ita", + pt: "por", + ru: "rus", + ja: "jpn", + ko: "kor", + zh: "chi", + ar: "ara", + pl: "pol", + nl: "dut", + sv: "swe", + no: "nor", + da: "dan", + fi: "fin", + tr: "tur", + cs: "cze", + el: "gre", + he: "heb", + hu: "hun", + ro: "rum", + th: "tha", + vi: "vie", + id: "ind", + ms: "may", + bg: "bul", + hr: "hrv", + sk: "slo", + sl: "slv", + uk: "ukr", +}; + +// Reverse mapping: 3-letter to 2-letter +const ISO_639_REVERSE: Record = Object.fromEntries( + Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]), +); + +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * OpenSubtitles REST API uses 2-letter codes + */ +function toIso6391(code: string): string { + if (code.length === 2) return code; + return ISO_639_REVERSE[code.toLowerCase()] || code; +} + /** * OpenSubtitles API client for direct subtitle fetching */ @@ -138,7 +190,7 @@ export class OpenSubtitlesApi { const queryParams = new URLSearchParams(); if (params.imdbId) { - // Ensure IMDB ID has correct format (with "tt" prefix) + // Ensure IMDB ID has "tt" prefix const imdbId = params.imdbId.startsWith("tt") ? params.imdbId : `tt${params.imdbId}`; @@ -151,7 +203,12 @@ export class OpenSubtitlesApi { queryParams.set("year", params.year.toString()); } if (params.languages) { - queryParams.set("languages", params.languages); + // Convert 3-letter codes to 2-letter codes (API uses ISO 639-1) + const lang = + params.languages.length === 3 + ? toIso6391(params.languages) + : params.languages; + queryParams.set("languages", lang); } if (params.seasonNumber !== undefined) { queryParams.set("season_number", params.seasonNumber.toString()); @@ -179,50 +236,18 @@ export class OpenSubtitlesApi { } } +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * Exported for external use + */ +export { toIso6391 }; + /** * Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code - * OpenSubtitles uses ISO 639-2B codes */ export function toIso6392B(code: string): string { - const mapping: Record = { - en: "eng", - es: "spa", - fr: "fre", - de: "ger", - it: "ita", - pt: "por", - ru: "rus", - ja: "jpn", - ko: "kor", - zh: "chi", - ar: "ara", - pl: "pol", - nl: "dut", - sv: "swe", - no: "nor", - da: "dan", - fi: "fin", - tr: "tur", - cs: "cze", - el: "gre", - he: "heb", - hu: "hun", - ro: "rum", - th: "tha", - vi: "vie", - id: "ind", - ms: "may", - bg: "bul", - hr: "hrv", - sk: "slo", - sl: "slv", - uk: "ukr", - }; - - // If already 3 letters, return as-is if (code.length === 3) return code; - - return mapping[code.toLowerCase()] || code; + return ISO_639_MAPPING[code.toLowerCase()] || code; } /** From 67bca1f98973a2d7a946879266d166c37b13a63f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 21:10:25 +0100 Subject: [PATCH 184/309] refactor(tv): have section pages fill width --- components/home/Favorites.tv.tsx | 2 -- components/home/Home.tv.tsx | 1 - .../home/InfiniteScrollingCollectionList.tv.tsx | 10 +++++++--- components/home/StreamystatsPromotedWatchlists.tv.tsx | 8 ++++++-- components/home/StreamystatsRecommendations.tv.tsx | 8 ++++++-- components/search/TVSearchPage.tsx | 11 +++++++---- components/search/TVSearchSection.tsx | 8 ++++++-- 7 files changed, 32 insertions(+), 16 deletions(-) diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx index c0da22207..b76a7fe83 100644 --- a/components/home/Favorites.tv.tsx +++ b/components/home/Favorites.tv.tsx @@ -178,8 +178,6 @@ export const Favorites = () => { contentContainerStyle={{ paddingTop: insets.top + TOP_PADDING, paddingBottom: insets.bottom + 60, - paddingLeft: insets.left + HORIZONTAL_PADDING, - paddingRight: insets.right + HORIZONTAL_PADDING, }} > diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index edea23d38..dbeccca25 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -737,7 +737,6 @@ export const Home = () => { diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index c3e1aa34c..ce32656df 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -251,7 +251,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} > @@ -263,7 +263,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ style={{ color: "#737373", fontSize: typography.callout, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.horizontal, }} > {t("home.no_items")} @@ -329,9 +329,13 @@ export const InfiniteScrollingCollectionList: React.FC = ({ removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, }} ListFooterComponent={ = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} > @@ -188,9 +188,13 @@ const WatchlistSection: React.FC = ({ removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, }} /> )} diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index b72d120d3..a35da2591 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -197,7 +197,7 @@ export const StreamystatsRecommendations: React.FC = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: sizes.padding.scale, + marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} > @@ -240,9 +240,13 @@ export const StreamystatsRecommendations: React.FC = ({ removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ paddingVertical: sizes.padding.scale, - paddingHorizontal: sizes.padding.scale, }} /> )} diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index feba7c2d1..69c7fc216 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -222,12 +222,15 @@ export const TVSearchPage: React.FC = ({ contentContainerStyle={{ paddingTop: insets.top + TOP_PADDING, paddingBottom: insets.bottom + 60, - paddingLeft: insets.left + HORIZONTAL_PADDING, - paddingRight: insets.right + HORIZONTAL_PADDING, }} > {/* Search Input */} - + = ({ {/* Search Type Tab Badges */} {showDiscover && ( - + = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} > @@ -293,9 +293,13 @@ export const TVSearchSection: React.FC = ({ removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, }} /> From 3438e78cab95439ed38940a5e0d7c006026407c1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 22:04:53 +0100 Subject: [PATCH 185/309] feat(tv): implement edge-to-edge horizontal sections for apple tv-like experience --- components/home/TVHeroCarousel.tsx | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 8118245bf..0d5b6bac8 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -206,7 +206,7 @@ export const TVHeroCarousel: React.FC = ({ const typography = useScaledTVTypography(); const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); - const insets = useSafeAreaInsets(); + const _insets = useSafeAreaInsets(); const router = useRouter(); // Active item for featured display (debounced) @@ -465,13 +465,14 @@ export const TVHeroCarousel: React.FC = ({ /> - {/* Content overlay */} + {/* Content overlay - text elements with padding */} {/* Logo or Title */} @@ -536,7 +537,6 @@ export const TVHeroCarousel: React.FC = ({ flexDirection: "row", alignItems: "center", gap: 16, - marginBottom: 20, }} > {year && ( @@ -616,14 +616,28 @@ export const TVHeroCarousel: React.FC = ({ )} + - {/* Thumbnail carousel */} + {/* Thumbnail carousel - edge-to-edge */} + Date: Tue, 19 May 2026 19:58:56 +0200 Subject: [PATCH 186/309] chore(deps): Update crowdin/github-action action to v2.16.2 (#1504) --- .github/workflows/crowdin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 66ce05591..c6effebf1 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -28,7 +28,7 @@ jobs: fetch-depth: 0 - name: 🌐 Sync Translations with Crowdin - uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1 + uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2 with: upload_sources: true upload_translations: true From fec8df37f773e59b360702fbcb5b15e99c102b9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 23:40:40 +0200 Subject: [PATCH 187/309] chore(deps): Pin dependencies (#1537) --- .github/workflows/build-apps.yml | 2 +- .github/workflows/update-issue-form.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index b300c04d4..9a03648cf 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -221,7 +221,7 @@ jobs: xcode-version: "26.2" - name: 🏗️ Setup EAS - uses: expo/expo-github-action@main + uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 25fa33196..d04631162 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -28,7 +28,7 @@ jobs: - name: 🔍 Extract minor version from app.json id: minor - uses: actions/github-script@main + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main with: result-encoding: string script: | From 222ae696449f990ca97e1067e026d7fc54f84397 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 00:03:36 +0200 Subject: [PATCH 188/309] chore(deps): Update marocchino/sticky-pull-request-comment action to v3 (#1512) --- .github/workflows/linting.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 08e0a8847..1a9c3e39a 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -25,7 +25,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 if: always() && (steps.lint_pr_title.outputs.error_message != null) with: header: pr-title-lint-error @@ -39,7 +39,7 @@ jobs: ``` - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: header: pr-title-lint-error delete: true From 7ed0c00ce7506700be0272b933333837c07d9bb2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 00:24:58 +0200 Subject: [PATCH 189/309] chore(deps): Update actions/upload-artifact action to v7 (#1510) --- .github/workflows/build-apps.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 9a03648cf..82c4a4186 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -88,7 +88,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} path: | @@ -171,7 +171,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} path: | @@ -236,7 +236,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload IPA artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} path: build-*.ipa @@ -293,7 +293,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload IPA artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} path: build/*.ipa From 8c749cdc4d5524594edf18e6195eaa01429fe9cf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 00:44:45 +0200 Subject: [PATCH 190/309] chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1509) --- .github/workflows/build-apps.yml | 8 ++++---- .github/workflows/check-lockfile.yml | 2 +- .github/workflows/linting.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 82c4a4186..492c0a636 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -41,7 +41,7 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -124,7 +124,7 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -195,7 +195,7 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -259,7 +259,7 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index ad189f7f3..99a2a490d 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 1a9c3e39a..9cc1b4bf0 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -76,7 +76,7 @@ jobs: fetch-depth: 0 - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -112,7 +112,7 @@ jobs: node-version: '24.x' - name: "🍞 Setup Bun" - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest From 428455f6a66686cfbe5392ad701acc66ac1341ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 01:08:17 +0200 Subject: [PATCH 191/309] chore(deps): Update actions/setup-node action to v6.4.0 (#1503) --- .github/workflows/linting.yml | 2 +- .github/workflows/update-issue-form.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 9cc1b4bf0..45ee39f35 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -107,7 +107,7 @@ jobs: fetch-depth: 0 - name: "🟢 Setup Node.js" - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index d04631162..69fad2eba 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "🟢 Setup Node.js" - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' cache: 'npm' From ece5750d3460d5c1c5b13d14b4d39d78796c129a Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 20 May 2026 07:56:39 +0200 Subject: [PATCH 192/309] Feat/tv interface uniform scale (#1562) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app.json | 2 +- app/(auth)/(tabs)/_layout.tsx | 2 +- assets/icons/gear.png | Bin 0 -> 26160 bytes components/home/Home.tv.tsx | 26 ++++++- .../InfiniteScrollingCollectionList.tv.tsx | 40 +++++++---- .../StreamystatsPromotedWatchlists.tv.tsx | 19 ++--- .../home/StreamystatsRecommendations.tv.tsx | 5 +- components/home/TVHeroCarousel.tsx | 65 +++++++++++------- components/tv/TVPosterCard.tsx | 64 ++++++++++------- constants/TVSizes.ts | 26 +++---- constants/TVTypography.ts | 27 ++++---- .../modules/mpvplayer/MPVLayerRenderer.kt | 19 ++++- utils/scaleSize.ts | 9 +++ 13 files changed, 197 insertions(+), 107 deletions(-) create mode 100644 assets/icons/gear.png create mode 100644 utils/scaleSize.ts diff --git a/app.json b/app.json index f9dfbb6da..5974f6f1b 100644 --- a/app.json +++ b/app.json @@ -85,7 +85,7 @@ "useFrameworks": "static" }, "android": { - "buildArchs": ["arm64-v8a", "x86_64"], + "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], "compileSdkVersion": 36, "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 1ed67edae..b92f3b136 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -134,7 +134,7 @@ export default function TabLayout() { tabBarItemHidden: !Platform.isTV, tabBarIcon: Platform.OS === "android" - ? (_e) => require("@/assets/icons/list.png") + ? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform : (_e) => ({ sfSymbol: "gearshape.fill" }), }} /> diff --git a/assets/icons/gear.png b/assets/icons/gear.png new file mode 100644 index 0000000000000000000000000000000000000000..f5b98cf07201d2c8774795c792d6174b944ef928 GIT binary patch literal 26160 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 { const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); + // Log TV viewport dimensions for DPI scaling debug + useEffect(() => { + const w = Dimensions.get("window"); + const s = Dimensions.get("screen"); + console.log("========== TV DIMENSIONS =========="); + console.log("Platform.OS:", Platform.OS, "isTV:", Platform.isTV); + console.log("Window:", w.width, "x", w.height); + console.log("Screen:", s.width, "x", s.height); + console.log("PixelRatio:", PixelRatio.get()); + console.log( + "scaleSize(210):", + 210 * Math.min(w.width / 1920, w.height / 1080), + ); + console.log("===================================="); + }, []); + // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); const debounceTimerRef = useRef | null>(null); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ce32656df..64869ec1b 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -24,9 +24,10 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; +import { scaleSize } from "@/utils/scaleSize"; // Extra padding to accommodate scale animation (1.05x) and glow shadow -const SCALE_PADDING = 20; +const SCALE_PADDING = scaleSize(20); interface Props extends ViewProps { title?: string | null; @@ -81,7 +82,7 @@ const TVSeeAllCard: React.FC<{ style={{ width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(24), backgroundColor: "rgba(255, 255, 255, 0.08)", justifyContent: "center", alignItems: "center", @@ -91,9 +92,9 @@ const TVSeeAllCard: React.FC<{ > = ({ fontSize: typography.heading, fontWeight: "700", color: "#FFFFFF", - marginBottom: 20, + marginBottom: scaleSize(20), marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} @@ -286,8 +287,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({ backgroundColor: "#262626", width: itemWidth, aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> = ({ removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} - contentInset={{ - left: sizes.padding.horizontal, - right: sizes.padding.horizontal, - }} - contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} contentContainerStyle={{ - paddingVertical: SCALE_PADDING, + paddingVertical: sizes.gaps.small, + paddingLeft: sizes.padding.horizontal, + paddingRight: sizes.padding.horizontal, }} + // Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove + // ListHeaderComponent={ + // + // } + // contentInset={{ + // left: sizes.padding.horizontal, + // right: sizes.padding.horizontal, + // }} + // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + // contentContainerStyle={{ paddingVertical: SCALE_PADDING }} ListFooterComponent={ {isFetchingNextPage && ( @@ -350,7 +359,10 @@ export const InfiniteScrollingCollectionList: React.FC = ({ marginLeft: itemWidth / 2, marginRight: ITEM_GAP, justifyContent: "center", - height: orientation === "horizontal" ? 191 : 315, + height: + orientation === "horizontal" + ? scaleSize(191) + : scaleSize(315), }} > diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index f2ae66a89..27730a1ec 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -19,10 +19,11 @@ import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; -const SCALE_PADDING = 20; +const SCALE_PADDING = scaleSize(20); interface WatchlistSectionProps extends ViewProps { watchlist: StreamystatsWatchlist; @@ -168,8 +169,8 @@ const WatchlistSection: React.FC = ({ backgroundColor: "#262626", width: posterSizes.poster, aspectRatio: 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> @@ -286,12 +287,12 @@ export const StreamystatsPromotedWatchlists: React.FC< diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index a35da2591..151d37a2e 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -18,6 +18,7 @@ import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; @@ -220,8 +221,8 @@ export const StreamystatsRecommendations: React.FC = ({ backgroundColor: "#262626", width: sizes.posters.poster, aspectRatio: 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 0d5b6bac8..7a71fbfbc 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -33,6 +33,7 @@ import { import { apiAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { scaleSize } from "@/utils/scaleSize"; import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -129,7 +130,7 @@ const HeroCard: React.FC = React.memo( = React.memo( style={{ width: sizes.posters.episode, aspectRatio: 16 / 9, - borderRadius: 24, + borderRadius: scaleSize(24), overflow: "hidden", transform: [{ scale }], - borderWidth: 2, + borderWidth: scaleSize(2), borderColor: focused ? "#FFFFFF" : "transparent", shadowColor: "#FFFFFF", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.6 : 0, - shadowRadius: focused ? 20 : 0, + shadowRadius: focused ? scaleSize(20) : 0, }} > {posterUrl ? ( @@ -183,7 +184,7 @@ const HeroCard: React.FC = React.memo( > @@ -472,7 +473,10 @@ export const TVHeroCarousel: React.FC = ({ left: sizes.padding.horizontal, right: sizes.padding.horizontal, bottom: - 40 + sizes.posters.episode * (9 / 16) + sizes.gaps.small * 2 + 20, + scaleSize(40) + + sizes.posters.episode * (9 / 16) + + sizes.gaps.small * 2 + + scaleSize(20), }} > {/* Logo or Title */} @@ -480,9 +484,9 @@ export const TVHeroCarousel: React.FC = ({ = ({ fontSize: typography.display, fontWeight: "bold", color: "#FFFFFF", - marginBottom: 12, + marginBottom: scaleSize(12), }} numberOfLines={1} > @@ -507,7 +511,7 @@ export const TVHeroCarousel: React.FC = ({ style={{ fontSize: typography.body, color: "rgba(255,255,255,0.9)", - marginBottom: 12, + marginBottom: scaleSize(12), }} numberOfLines={1} > @@ -521,7 +525,7 @@ export const TVHeroCarousel: React.FC = ({ style={{ fontSize: typography.body, color: "rgba(255,255,255,0.8)", - marginBottom: 16, + marginBottom: scaleSize(16), maxWidth: SCREEN_WIDTH * 0.5, lineHeight: typography.body * 1.4, }} @@ -536,7 +540,7 @@ export const TVHeroCarousel: React.FC = ({ style={{ flexDirection: "row", alignItems: "center", - gap: 16, + gap: scaleSize(16), }} > {year && ( @@ -562,10 +566,10 @@ export const TVHeroCarousel: React.FC = ({ {activeItem?.OfficialRating && ( @@ -584,15 +588,15 @@ export const TVHeroCarousel: React.FC = ({ style={{ flexDirection: "row", alignItems: "center", - gap: 6, + gap: scaleSize(6), }} > @@ -624,7 +628,7 @@ export const TVHeroCarousel: React.FC = ({ position: "absolute", left: 0, right: 0, - bottom: 40, + bottom: scaleSize(40), }} > = ({ keyExtractor={keyExtractor} showsHorizontalScrollIndicator={false} style={{ overflow: "visible" }} - contentInset={{ - left: sizes.padding.horizontal, - right: sizes.padding.horizontal, + contentContainerStyle={{ + paddingVertical: sizes.gaps.small, + paddingLeft: sizes.padding.horizontal, + paddingRight: sizes.padding.horizontal, }} - contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} - contentContainerStyle={{ paddingVertical: sizes.gaps.small }} + // Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove + // ListHeaderComponent={ + // + // } + // contentInset={{ + // left: sizes.padding.horizontal, + // right: sizes.padding.horizontal, + // }} + // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + // contentContainerStyle={{ paddingVertical: sizes.gaps.small }} renderItem={renderHeroCard} removeClippedSubviews={false} initialNumToRender={8} diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6cb4fa827..83274ea3b 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -21,6 +21,7 @@ import { } from "@/modules/glass-poster"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { scaleSize } from "@/utils/scaleSize"; import { runtimeTicksToMinutes } from "@/utils/time"; export interface TVPosterCardProps { @@ -225,7 +226,13 @@ export const TVPosterCard: React.FC = ({ : null; return ( - + {episodeLabel && ( = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ChannelName} @@ -277,7 +284,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {artist} @@ -296,7 +303,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {artist} @@ -312,7 +319,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ChildCount} tracks @@ -328,7 +335,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ProductionYear} @@ -344,23 +351,23 @@ export const TVPosterCard: React.FC = ({ - + @@ -382,7 +389,7 @@ export const TVPosterCard: React.FC = ({ justifyContent: "center", }} > - + ) : null; @@ -395,9 +402,9 @@ export const TVPosterCard: React.FC = ({ style={{ width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(24), backgroundColor: "#1a1a1a", - borderWidth: 2, + borderWidth: scaleSize(2), borderColor: focused ? "#FFFFFF" : "transparent", }} /> @@ -411,7 +418,7 @@ export const TVPosterCard: React.FC = ({ = ({ position: "relative", width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(4), overflow: "hidden", backgroundColor: "#1a1a1a", - borderWidth: 2, + borderWidth: scaleSize(2), borderColor: focused ? "#FFFFFF" : "transparent", }} > @@ -470,7 +477,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#FFFFFF", - marginTop: 4, + marginTop: scaleSize(4), fontWeight: "500", }} > @@ -498,8 +505,13 @@ export const TVPosterCard: React.FC = ({ // Default: show name return ( {item.Name} @@ -551,7 +563,7 @@ export const TVPosterCard: React.FC = ({ shadowColor: useGlass ? undefined : shadowColor, shadowOffset: useGlass ? undefined : { width: 0, height: 0 }, shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0, - shadowRadius: useGlass ? undefined : focused ? 12 : 0, + shadowRadius: useGlass ? undefined : focused ? scaleSize(12) : 0, }} > {renderPosterImage()} @@ -560,7 +572,9 @@ export const TVPosterCard: React.FC = ({ {/* Text below poster */} {showText && ( - + {item.Type === "Episode" ? ( <> {renderSubtitle()} diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts index 20c38daa9..676609bca 100644 --- a/constants/TVSizes.ts +++ b/constants/TVSizes.ts @@ -1,10 +1,12 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; /** * TV Layout Sizes * * Unified constants for TV interface layout including posters, gaps, and padding. - * All values scale based on the user's tvTypographyScale setting. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. */ // ============================================================================= @@ -48,7 +50,7 @@ export const TVGaps = { */ export const TVPadding = { /** Horizontal padding from screen edges */ - horizontal: 60, + horizontal: 90, /** Padding to accommodate scale animations (1.05x) */ scale: 20, @@ -129,20 +131,20 @@ export const useScaledTVSizes = (): ScaledTVSizes => { return { posters: { - poster: Math.round(TVPosterSizes.poster * scale), - landscape: Math.round(TVPosterSizes.landscape * scale), - episode: Math.round(TVPosterSizes.episode * scale), + poster: Math.round(scaleSize(TVPosterSizes.poster) * scale), + landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale), + episode: Math.round(scaleSize(TVPosterSizes.episode) * scale), }, gaps: { - item: Math.round(TVGaps.item * scale), - section: Math.round(TVGaps.section * scale), - small: Math.round(TVGaps.small * scale), - large: Math.round(TVGaps.large * scale), + item: Math.round(scaleSize(TVGaps.item) * scale), + section: Math.round(scaleSize(TVGaps.section) * scale), + small: Math.round(scaleSize(TVGaps.small) * scale), + large: Math.round(scaleSize(TVGaps.large) * scale), }, padding: { - horizontal: Math.round(TVPadding.horizontal * scale), - scale: Math.round(TVPadding.scale * scale), - vertical: Math.round(TVPadding.vertical * scale), + horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale), + scale: Math.round(scaleSize(TVPadding.scale) * scale), + vertical: Math.round(scaleSize(TVPadding.vertical) * scale), heroHeight: TVPadding.heroHeight * scale, }, animation: TVAnimation, diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index a2ac3b804..833f617e3 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -4,25 +4,28 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; * TV Typography Scale * * Consistent text sizes for TV interface components. - * These sizes are optimized for TV viewing distance. + * Design values are for 1920×1080 and scaled proportionally + * to the actual viewport via scaleSize(). */ +import { scaleSize } from "@/utils/scaleSize"; + export const TVTypography = { - /** Hero titles, movie/show names - 70px */ - display: 70, + /** Hero titles, movie/show names */ + display: scaleSize(70), - /** Episode series name, major headings - 42px */ - title: 42, + /** Episode series name, major headings */ + title: scaleSize(42), - /** Section headers (Cast, Technical Details, From this Series) - 32px */ - heading: 32, + /** Section headers (Cast, Technical Details, From this Series) */ + heading: scaleSize(32), - /** Overview, actor names, card titles, metadata - 20px */ - body: 20, + /** Overview, actor names, card titles, metadata */ + body: scaleSize(40), - /** Secondary text, labels, subtitles - 16px */ - callout: 16, -} as const; + /** Secondary text, labels, subtitles */ + callout: scaleSize(26), +}; export type TVTypographyKey = keyof typeof TVTypography; diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 38c55625c..8860932e1 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -1,6 +1,8 @@ package expo.modules.mpvplayer +import android.app.UiModeManager import android.content.Context +import android.content.res.Configuration import android.content.res.AssetManager import android.os.Handler import android.os.Looper @@ -27,7 +29,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { const val MPV_FORMAT_DOUBLE = 5 const val MPV_FORMAT_NODE = 6 } - + + private fun isTvDevice(): Boolean { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + interface Delegate { fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) fun onPauseChanged(isPaused: Boolean) @@ -157,7 +164,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.setOptionString("opengl-es", "yes") // Hardware video decoding - MPVLib.setOptionString("hwdec", "mediacodec-copy") + // TV: zero-copy (mediacodec) for better performance on low-power devices + // Mobile: copy mode (mediacodec-copy) for better compatibility + val isTV = isTvDevice() + if (isTV) { + MPVLib.setOptionString("hwdec", "mediacodec") + MPVLib.setOptionString("profile", "fast") + } else { + MPVLib.setOptionString("hwdec", "mediacodec-copy") + } MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") // Cache settings for better network streaming diff --git a/utils/scaleSize.ts b/utils/scaleSize.ts new file mode 100644 index 000000000..09cc9a568 --- /dev/null +++ b/utils/scaleSize.ts @@ -0,0 +1,9 @@ +import { Dimensions } from "react-native"; + +const { width: W, height: H } = Dimensions.get("window"); + +export const scaleSize = (size: number): number => { + const widthRatio = W / 1920; + const heightRatio = H / 1080; + return size * Math.min(widthRatio, heightRatio); +}; From 92deba14f3260980fbda38aeb9c4dd2c673d44fc Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 20 May 2026 08:41:49 +0200 Subject: [PATCH 193/309] Adding QR code login (#1557) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app.config.js | 8 + app/(auth)/(tabs)/(home)/_layout.tsx | 19 + app/(auth)/(tabs)/(home)/companion-login.tsx | 7 + app/(auth)/(tabs)/(home)/settings.tsx | 12 + bun.lock | 54 +- components/companion/CompanionLoginScreen.tsx | 507 ++++++++++++++++++ components/login/TVAddServerForm.tsx | 44 +- components/login/TVAddUserForm.tsx | 30 +- components/login/TVLogin.tsx | 234 +++++++- components/login/TVQRCodeDisplay.tsx | 201 +++++++ package.json | 2 + translations/en.json | 31 ++ utils/pairingService.ts | 142 +++++ 13 files changed, 1283 insertions(+), 8 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/companion-login.tsx create mode 100644 components/companion/CompanionLoginScreen.tsx create mode 100644 components/login/TVQRCodeDisplay.tsx create mode 100644 utils/pairingService.ts diff --git a/app.config.js b/app.config.js index 2e37927bc..96bbd8ea0 100644 --- a/app.config.js +++ b/app.config.js @@ -6,6 +6,14 @@ module.exports = ({ config }) => { "react-native-google-cast", { useDefaultExpandedMediaControls: true }, ]); + + config.plugins.push([ + "expo-camera", + { + cameraPermission: + "Allow Streamyfin to access the camera to scan QR codes for TV login.", + }, + ]); } // Only override googleServicesFile if env var is set diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index ec8b3517b..591759b94 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -96,6 +96,25 @@ export default function IndexLayout() { ), }} /> + ( + _router.back()} + className='pl-0.5' + style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} + > + + + ), + }} + /> ; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 1ed36fe2f..4c38659eb 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -59,6 +59,18 @@ function SettingsMobile() { + router.push("/(auth)/(tabs)/(home)/companion-login")} + > + + {t("pairing.pair_with_phone_title")} + + + {t("pairing.pair_with_phone_description")} + + + diff --git a/bun.lock b/bun.lock index 7abad3e17..ab9acf3e4 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "^55.0.18", "expo-constants": "18.0.13", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -77,6 +78,7 @@ "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", "react-native-safe-area-context": "~5.6.0", @@ -621,6 +623,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], + "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], @@ -761,6 +765,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "barcode-detector": ["barcode-detector@3.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="], @@ -929,6 +935,8 @@ "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -1019,6 +1027,8 @@ "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], + "expo-camera": ["expo-camera@55.0.18", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA=="], + "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="], @@ -1573,7 +1583,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1617,6 +1627,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], @@ -1685,6 +1697,8 @@ "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], + "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="], + "react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="], @@ -1889,6 +1903,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], @@ -1901,6 +1917,8 @@ "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + "text-encoding": ["text-encoding@0.7.0", "", {}, "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -2045,6 +2063,8 @@ "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], @@ -2409,12 +2429,16 @@ "parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -2423,6 +2447,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -2489,6 +2515,8 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], @@ -2981,6 +3009,14 @@ "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3165,6 +3201,12 @@ "metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3201,6 +3243,10 @@ "logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], @@ -3213,6 +3259,12 @@ "logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], } } diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx new file mode 100644 index 000000000..8ca1490ee --- /dev/null +++ b/components/companion/CompanionLoginScreen.tsx @@ -0,0 +1,507 @@ +import { Camera, CameraView } from "expo-camera"; +import { useAtom } from "jotai"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + KeyboardAvoidingView, + Linking, + Platform, + ScrollView, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { sendCredentialsToTV } from "@/utils/pairingService"; + +type ScreenState = + | "scanning" + | "no-permission" + | "confirm" + | "form" + | "sending" + | "success" + | "error"; + +interface ParsedPairingCode { + code: string; +} + +export const CompanionLoginScreen: React.FC = () => { + const { t } = useTranslation(); + const router = useRouter(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [screenState, setScreenState] = useState("scanning"); + const [pairingCode, setPairingCode] = useState(""); + const [serverUrl, setServerUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + // Pre-fill server URL and username from current session + useEffect(() => { + if (api?.basePath) { + setServerUrl(api.basePath); + } + + if (user?.Name) { + setUsername(user.Name); + } + }, [api?.basePath, user?.Name]); + + // Request camera permission + useEffect(() => { + Camera.getCameraPermissionsAsync().then((response) => { + if (!response.granted) { + Camera.requestCameraPermissionsAsync().then((result) => { + if (!result.granted) { + setScreenState("no-permission"); + } + }); + } + }); + }, []); + + const validateAndParseQR = useCallback( + (data: string): ParsedPairingCode | null => { + try { + const parsed = JSON.parse(data); + + if ( + parsed.action === "streamyfin-pair" && + typeof parsed.code === "string" && + parsed.code.length > 0 + ) { + return { code: parsed.code }; + } + + return null; + } catch { + return null; + } + }, + [], + ); + + const handleBarCodeScanned = useCallback( + ({ data }: { data: string }) => { + if (screenState !== "scanning") return; + + const parsed = validateAndParseQR(data); + + if (!parsed) { + setErrorMessage(t("companion_login.error_invalid_qr")); + setScreenState("error"); + return; + } + + setPairingCode(parsed.code); + + // If user is logged in, show confirmation screen (still needs password) + // Otherwise, go straight to the full form + if (user?.Name && api?.basePath) { + setScreenState("confirm"); + } else { + setScreenState("form"); + } + }, + [screenState, validateAndParseQR, t, user?.Name, api?.basePath], + ); + + const handleSendCredentials = useCallback(async () => { + if ( + !serverUrl.trim() || + !username.trim() || + !password.trim() || + !pairingCode + ) { + return; + } + + setScreenState("sending"); + + try { + await sendCredentialsToTV( + pairingCode, + serverUrl.trim(), + username.trim(), + password, + ); + + setScreenState("success"); + } catch { + setErrorMessage(t("companion_login.error_generic")); + setScreenState("error"); + } + }, [pairingCode, serverUrl, username, password, t]); + + const handleScanAgain = useCallback(() => { + setPairingCode(""); + setErrorMessage(null); + setPassword(""); + setScreenState("scanning"); + }, []); + + const handleDone = useCallback(() => { + router.back(); + }, [router]); + + const handleUseDifferentUser = useCallback(() => { + setUsername(""); + setPassword(""); + setScreenState("form"); + }, []); + + const handleEnterCodeManually = useCallback(() => { + setScreenState("form"); + }, []); + + if (screenState === "no-permission") { + return ( + + + + {t("companion_login.error_permission_denied")} + + + {Platform.OS === "ios" && ( + Linking.openSettings()} + className='mt-4 rounded-lg bg-purple-600 px-6 py-3' + > + + {t("companion_login.open_settings")} + + + )} + + + + + ); + } + + if (screenState === "success") { + return ( + + + + {t("companion_login.success_title")} + + + + {t("companion_login.pairing_tv_connecting")} + + + + + + ); + } + + if (screenState === "error") { + return ( + + + + {t("companion_login.error_title")} + + + + {errorMessage} + + + + + + + + + + ); + } + + if (screenState === "sending") { + return ( + + + + {t("companion_login.authorizing")} + + + + ); + } + + if (screenState === "confirm") { + return ( + + + + {t("companion_login.login_as", { username })} + + + + {t("companion_login.on_server", { + server: serverUrl.replace(/^https?:\/\//, ""), + })} + + + + + {t("companion_login.pairing_code_label")} + + + + {pairingCode} + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + {t("companion_login.use_different_user")} + + + + + + {t("companion_login.scan_again")} + + + + + + ); + } + + if (screenState === "form") { + return ( + + + + {t("companion_login.pairing_enter_credentials")} + + + + + {t("companion_login.pairing_code_label")} + + + + + + + + {t("companion_login.server")} + + + + + + + + {t("login.username_placeholder")} + + + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + ); + } + + return ( + + {/* Camera full screen */} + + + {/* Dark overlay */} + + + {/* Center scan area */} + + + + + {t("companion_login.align_qr")} + + + + + {t("companion_login.enter_code_manually")} + + + + + ); +}; diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx index 7d3cefe17..ab74c55a3 100644 --- a/components/login/TVAddServerForm.tsx +++ b/components/login/TVAddServerForm.tsx @@ -1,7 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useRef, useState } from "react"; -import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + BackHandler, + Easing, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -9,6 +17,7 @@ import { TVInput } from "./TVInput"; interface TVAddServerFormProps { onConnect: (url: string) => Promise; + onStartPairing?: () => void; onBack: () => void; loading?: boolean; disabled?: boolean; @@ -78,6 +87,7 @@ const TVBackButton: React.FC<{ export const TVAddServerForm: React.FC = ({ onConnect, + onStartPairing, onBack, loading = false, disabled = false, @@ -93,6 +103,24 @@ export const TVAddServerForm: React.FC = ({ const isDisabled = disabled || loading; + // Handle Android TV back button, needed as an "override" + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (disabled) return false; + onBack(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onBack, disabled]); + return ( = ({ > {t("server.enter_url_to_jellyfin_server")} + + {/* Pair with Phone */} + {onStartPairing && ( + + + + )} ); diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx index 17f8fe89a..d75bc085f 100644 --- a/components/login/TVAddUserForm.tsx +++ b/components/login/TVAddUserForm.tsx @@ -1,7 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useRef, useState } from "react"; -import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + BackHandler, + Easing, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -108,6 +116,24 @@ export const TVAddUserForm: React.FC = ({ const isDisabled = disabled || loading; + // Handle Android TV back button, needed as an "override" + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (disabled) return false; + onBack(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onBack, disabled]); + return ( { const isAnyModalOpen = showSaveModal || pinModalVisible || passwordModalVisible; + // Pairing state (companion login via phone) + const [showPairingQR, setShowPairingQR] = useState(false); + const [pairingCode, setPairingCode] = useState(""); + const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{ + serverUrl: string; + username: string; + password: string; + } | null>(null); + // Ref to prevent double-handling when onSave and onClose both fire + const pairingHandledRef = useRef(false); + // Refresh servers list helper const refreshServers = () => { const servers = getPreviousServers(); @@ -119,6 +142,7 @@ export const TVLogin: React.FC = () => { useEffect(() => { return () => { stopQuickConnectPolling(); + setShowPairingQR(false); }; }, [stopQuickConnectPolling]); @@ -262,6 +286,7 @@ export const TVLogin: React.FC = () => { switch (account.securityType) { case "none": + setCurrentScreen("loading"); setLoading(true); try { await loginWithSavedCredential(currentServer.address, account.userId); @@ -281,7 +306,7 @@ export const TVLogin: React.FC = () => { [ { text: t("common.ok"), - onPress: () => setCurrentScreen("add-user"), + onPress: () => setCurrentScreen("user-selection"), }, ], ); @@ -306,6 +331,7 @@ export const TVLogin: React.FC = () => { const handlePinSuccess = async () => { setPinModalVisible(false); if (currentServer && selectedAccount) { + setCurrentScreen("loading"); setLoading(true); try { await loginWithSavedCredential( @@ -323,6 +349,12 @@ export const TVLogin: React.FC = () => { ? t("server.session_expired") : t("login.connection_failed"), isSessionExpired ? t("server.please_login_again") : errorMessage, + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], ); } finally { setLoading(false); @@ -334,6 +366,7 @@ export const TVLogin: React.FC = () => { // Handle password submit const handlePasswordSubmit = async (password: string) => { if (currentServer && selectedAccount) { + setCurrentScreen("loading"); setLoading(true); try { await loginWithPassword( @@ -345,6 +378,12 @@ export const TVLogin: React.FC = () => { Alert.alert( t("login.connection_failed"), t("login.invalid_username_or_password"), + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], ); } finally { setLoading(false); @@ -408,7 +447,63 @@ export const TVLogin: React.FC = () => { pinCode?: string, ) => { setShowSaveModal(false); + const pairingCreds = pendingPairingCredentials; + if (pairingCreds) { + // Pairing flow: mark as handled, login, then save credential + pairingHandledRef.current = true; + setPendingPairingCredentials(null); + setPendingLogin(null); + setLoading(true); + try { + await loginWithPassword( + pairingCreds.serverUrl, + pairingCreds.username, + pairingCreds.password, + ); + // Save credential after successful login + try { + const token = storage.getString("token"); + const userJson = storage.getString("user"); + const storedServerUrl = storage.getString("serverUrl"); + if (token && userJson && storedServerUrl) { + const user = JSON.parse(userJson); + let pinHash: string | undefined; + if (securityType === "pin" && pinCode) { + pinHash = await hashPIN(pinCode); + } + await saveAccountCredential({ + serverUrl: storedServerUrl, + serverName: storedServerUrl, + token, + userId: user.Id || "", + username: pairingCreds.username, + savedAt: Date.now(), + securityType, + pinHash, + primaryImageTag: user.PrimaryImageTag ?? undefined, + }); + } + } catch (saveError) { + console.error( + "[TVLogin] Failed to save pairing credential:", + saveError, + ); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occured"); + Alert.alert(t("login.connection_failed"), message); + goToQRScreen(); + } finally { + setLoading(false); + } + return; + } + + // Normal login flow if (pendingLogin && currentServer) { setLoading(true); try { @@ -452,11 +547,77 @@ export const TVLogin: React.FC = () => { } }; + // Navigate to QR screen with a fresh code and active listener + const goToQRScreen = useCallback(() => { + const code = generatePairingCode(); + setPairingCode(code); + setShowPairingQR(true); + setCurrentScreen("qr-code-display"); + }, []); + + // Handle pairing with companion phone + const handleStartPairing = useCallback(() => { + goToQRScreen(); + }, [goToQRScreen]); + + // Handle credentials received from companion + const handlePairingCredentials = useCallback( + (credentials: PairingCredentials) => { + setShowPairingQR(false); + setCurrentScreen("loading"); + + // Store credentials and show save modal (same UX as normal login) + setPendingPairingCredentials({ + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + }); + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + }, + [], + ); + + // Listen for pairing credentials when QR is shown + useEffect(() => { + if (!showPairingQR || !pairingCode) return; + + const cleanup = startPairingListener( + pairingCode, + handlePairingCredentials, + (error) => { + console.error("[TVLogin] Pairing error:", error); + setShowPairingQR(false); + Alert.alert(t("login.error_title"), t("companion_login.error_generic")); + }, + ); + + // Auto-dismiss after 5 minutes + const timeout = setTimeout( + () => { + setShowPairingQR(false); + }, + 5 * 60 * 1000, + ); + + return () => { + cleanup(); + clearTimeout(timeout); + }; + }, [showPairingQR, pairingCode, handlePairingCredentials]); + // Render current screen const renderScreen = () => { // If API is connected but we're on server/user selection, // it means we need to show add-user form - if (api?.basePath && currentScreen !== "add-user") { + if ( + api?.basePath && + currentScreen !== "add-user" && + currentScreen !== "loading" + ) { // API is ready, show add-user form return ( { return ( setCurrentScreen("server-selection")} loading={loadingServerCheck} disabled={isAnyModalOpen} /> ); + case "qr-code-display": + return ( + { + setShowPairingQR(false); + setCurrentScreen("add-server"); + }} + /> + ); + + case "loading": + return ( + + + {t("pairing.logging_in")} + + + {t("pairing.logging_in_description")} + + + ); + case "add-user": return ( { { + // If onSave already handled this, just clean up + if (pairingHandledRef.current) { + pairingHandledRef.current = false; + return; + } setShowSaveModal(false); + if (pendingPairingCredentials) { + // Pairing: user dismissed without saving, login anyway + const creds = pendingPairingCredentials; + setPendingPairingCredentials(null); + setPendingLogin(null); + loginWithPassword( + creds.serverUrl, + creds.username, + creds.password, + ).catch((error) => { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occured"); + Alert.alert(t("login.connection_failed"), message); + goToQRScreen(); + }); + return; + } setPendingLogin(null); }} onSave={handleSaveAccountConfirm} diff --git a/components/login/TVQRCodeDisplay.tsx b/components/login/TVQRCodeDisplay.tsx new file mode 100644 index 000000000..5ce6e56eb --- /dev/null +++ b/components/login/TVQRCodeDisplay.tsx @@ -0,0 +1,201 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import React, { useEffect, useRef } from "react"; +import { + Animated, + BackHandler, + Easing, + Platform, + Pressable, + View, +} from "react-native"; +import QRCode from "react-native-qrcode-svg"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVQRCodeDisplayProps { + code: string; + onBack?: () => void; +} + +export const TVQRCodeDisplay: React.FC = ({ + code, + onBack, +}) => { + const typography = useScaledTVTypography(); + const handledRef = useRef(false); + + const qrSize = scaleSize(280); + const cardPadding = scaleSize(16); + const sectionPadding = scaleSize(32); + const outerPadding = scaleSize(60); + + const qrData = JSON.stringify({ + action: "streamyfin-pair", + code, + }); + + // Handle Android TV back button + useEffect(() => { + if (!Platform.isTV || !onBack) return; + + const handleBackPress = () => { + if (handledRef.current) return true; + + handledRef.current = true; + setTimeout(() => { + handledRef.current = false; + }, 100); + + onBack(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onBack]); + + return ( + + + {/* Back Button */} + {onBack && } + + {/* QR Code */} + + + {t("pairing.waiting_for_phone")} + + + + + + + + {code} + + + + {t("pairing.scan_with_phone")} + + + + + ); +}; + +const TVBackButton: React.FC<{ + onPress: () => void; +}> = ({ onPress }) => { + const [isFocused, setIsFocused] = React.useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 24 }} + focusable + > + + + + {t("common.back")} + + + + ); +}; diff --git a/package.json b/package.json index f231e2bf7..b5f40efbd 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "^55.0.18", "expo-constants": "18.0.13", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -97,6 +98,7 @@ "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", "react-native-safe-area-context": "~5.6.0", diff --git a/translations/en.json b/translations/en.json index e53522959..e928bec00 100644 --- a/translations/en.json +++ b/translations/en.json @@ -979,5 +979,36 @@ "show": "This show", "all": "All media (default)" } + }, + "companion_login": { + "title": "Pair with TV", + "align_qr": "Align the QR code within the frame", + "enter_code_manually": "Enter code manually", + "pairing_enter_credentials": "Enter credentials for TV", + "pairing_code_label": "Pairing code", + "server": "Server", + "authorize_button": "Authorize", + "authorizing": "Authorizing...", + "scan_again": "Scan Again", + "done": "Done", + "success_title": "Authorization Sent", + "pairing_tv_connecting": "The TV is connecting to your account", + "error_title": "Authorization Failed", + "error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.", + "error_generic": "Something went wrong. Please try again.", + "error_permission_denied": "Camera permission is required to scan QR codes.", + "login_as": "Log in as {{username}}?", + "on_server": "on {{server}}", + "use_different_user": "Use a different user", + "open_settings": "Open Settings" + }, + "pairing": { + "pair_with_phone": "Pair with Phone", + "pair_with_phone_title": "Login TV", + "pair_with_phone_description": "Scan the QR code displayed on your TV to log in", + "waiting_for_phone": "Waiting for phone...", + "scan_with_phone": "Scan with the Streamyfin app on your phone", + "logging_in": "Logging in...", + "logging_in_description": "Connecting to your server" } } diff --git a/utils/pairingService.ts b/utils/pairingService.ts new file mode 100644 index 000000000..755661196 --- /dev/null +++ b/utils/pairingService.ts @@ -0,0 +1,142 @@ +import dgram from "react-native-udp"; + +const PAIRING_PORT = 54322; +const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response"; + +export interface PairingCredentials { + serverUrl: string; + username: string; + password: string; +} + +export function generatePairingCode(): string { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +export function startPairingListener( + code: string, + onCredentialsReceived: (credentials: PairingCredentials) => void, + onError?: (error: string) => void, +): () => void { + let active = true; + + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + socket.on("error", (err) => { + console.error("[PairingService] Socket error:", err); + onError?.(err.message); + cleanup(); + }); + + socket.bind(PAIRING_PORT, () => { + console.log("[PairingService] Listening on port", PAIRING_PORT); + }); + + socket.on("message", (msg) => { + if (!active) return; + + try { + const data = JSON.parse(new TextDecoder().decode(msg)); + + if (data.type !== PAIRING_MESSAGE_TYPE) return; + if (data.code !== code) return; + + if (!data.server_url || !data.username || !data.password) { + console.error("[PairingService] Missing fields in pairing response"); + return; + } + + console.log("[PairingService] Credentials received"); + active = false; + onCredentialsReceived({ + serverUrl: data.server_url, + username: data.username, + password: data.password, + }); + cleanup(); + } catch (error) { + console.error("[PairingService] Error parsing message:", error); + } + }); + + function cleanup() { + active = false; + try { + socket.close(); + } catch { + // Socket may already be closed + } + } + + return cleanup; +} + +export function sendCredentialsToTV( + code: string, + serverUrl: string, + username: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + const message = JSON.stringify({ + type: PAIRING_MESSAGE_TYPE, + code, + server_url: serverUrl, + username, + password, + }); + + const messageBuffer = new TextEncoder().encode(message); + + socket.on("error", (err) => { + reject(err); + try { + socket.close(); + } catch { + // Ignore + } + }); + + socket.bind(0, () => { + try { + socket.setBroadcast(true); + socket.send( + messageBuffer, + 0, + messageBuffer.length, + PAIRING_PORT, + "255.255.255.255", + (err) => { + try { + socket.close(); + } catch { + // Ignore + } + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + } catch (error) { + try { + socket.close(); + } catch { + // Ignore + } + reject(error); + } + }); + }); +} From ca4f24ded0381c3803cf1b11b1b6e61117138dbc Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Wed, 20 May 2026 08:57:19 +0100 Subject: [PATCH 194/309] fix: handle TV menu and back navigation (#1559) --- app/(auth)/(tabs)/_layout.tsx | 4 +- components/login/TVUserSelectionScreen.tsx | 13 +- .../controls/hooks/useRemoteControl.ts | 92 ++++--------- hooks/useTVBackHandler.ts | 129 +++--------------- hooks/useTVBackPress.ts | 57 ++++++++ hooks/useTVEventHandler.ts | 17 +++ 6 files changed, 123 insertions(+), 189 deletions(-) create mode 100644 hooks/useTVBackPress.ts create mode 100644 hooks/useTVEventHandler.ts diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index b92f3b136..29e706ad6 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { Colors } from "@/constants/Colors"; -import { useTVBackHandler } from "@/hooks/useTVBackHandler"; +import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -38,7 +38,7 @@ export default function TabLayout() { const { t } = useTranslation(); // Handle TV back button - prevent app exit when at root - useTVBackHandler(); + useTVHomeBackHandler(); return ( diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx index d9f74b371..97c269cf5 100644 --- a/components/login/TVUserSelectionScreen.tsx +++ b/components/login/TVUserSelectionScreen.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from "react"; import { BackHandler, Platform, ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; import type { SavedServer, SavedServerAccount, @@ -19,18 +20,6 @@ interface TVUserSelectionScreenProps { disabled?: boolean; } -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - useTVEventHandler = () => {}; - } -} else { - useTVEventHandler = () => {}; -} - export const TVUserSelectionScreen: React.FC = ({ server, onUserSelect, diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 513c8dd78..359b4100d 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,20 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { Alert, BackHandler, Platform } from "react-native"; +import { Alert } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; - -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - // Fallback for non-TV platforms - useTVEventHandler = () => {}; - } -} else { - // No-op hook for non-TV platforms - useTVEventHandler = () => {}; -} +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; interface UseRemoteControlProps { showControls: boolean; @@ -70,7 +58,6 @@ interface UseRemoteControlProps { */ export function useRemoteControl({ showControls, - toggleControls, togglePlay, onBack, onHideControls, @@ -106,61 +93,38 @@ export function useRemoteControl({ videoTitleRef.current = videoTitle; }, [showControls, onHideControls, onBack, videoTitle]); - // Handle hardware back button (works on both Android TV and tvOS) - useEffect(() => { - if (!Platform.isTV) return; - - const handleBackPress = () => { - if (showControlsRef.current && onHideControlsRef.current) { - // Controls are visible - just hide them - onHideControlsRef.current(); - return true; // Prevent default back navigation - } - if (onBackRef.current) { - // Controls are hidden - show confirmation before exiting - Alert.alert( - "Stop Playback", - videoTitleRef.current - ? `Stop playing "${videoTitleRef.current}"?` - : "Are you sure you want to stop playback?", - [ - { text: "Cancel", style: "cancel" }, - { text: "Stop", style: "destructive", onPress: onBackRef.current }, - ], - ); - return true; // Prevent default back navigation - } - return false; // Let default back navigation happen - }; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - handleBackPress, - ); - - return () => subscription.remove(); + // BackHandler owns player exit: Android TV sends hardware back here, and + // react-native-tvos maps the Apple TV menu button to the same API. + useTVBackPress(() => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible, so the first back press only hides them. + onHideControlsRef.current(); + return true; + } + if (onBackRef.current) { + // Controls are hidden, so confirm before leaving playback. + Alert.alert( + "Stop Playback", + videoTitleRef.current + ? `Stop playing "${videoTitleRef.current}"?` + : "Are you sure you want to stop playback?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop", style: "destructive", onPress: onBackRef.current }, + ], + ); + return true; + } + return false; }, []); // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - // Back/menu is handled by BackHandler above, but keep this for tvOS menu button + // Back/menu is handled by useTVBackPress above. Keep this handler focused + // on remote-control events like play/pause, D-pad, and long seek. if (evt.eventType === "menu") { - if (showControls && onHideControls) { - onHideControls(); - } else if (onBack) { - Alert.alert( - "Stop Playback", - videoTitle - ? `Stop playing "${videoTitle}"?` - : "Are you sure you want to stop playback?", - [ - { text: "Cancel", style: "cancel" }, - { text: "Stop", style: "destructive", onPress: onBack }, - ], - ); - } return; } diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts index dd5ca1062..8277d0a79 100644 --- a/hooks/useTVBackHandler.ts +++ b/hooks/useTVBackHandler.ts @@ -1,25 +1,12 @@ -import { useNavigation } from "@react-navigation/native"; -import { router, useSegments } from "expo-router"; -import { useEffect, useRef } from "react"; -import { BackHandler, Platform } from "react-native"; +import { useSegments } from "expo-router"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { + disableTVMenuKeyInterception, + enableTVMenuKeyInterception, +} from "./useTVBackPress"; -// TV event handler and control with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -let TVEventControl: { - enableTVMenuKey: () => void; - disableTVMenuKey: () => void; -} | null = null; - -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - TVEventControl = require("react-native").TVEventControl; - } catch { - useTVEventHandler = () => {}; - } -} else { - useTVEventHandler = () => {}; -} +export { enableTVMenuKeyInterception } from "./useTVBackPress"; /** * Check if we're at the root of a tab @@ -55,106 +42,26 @@ function getCurrentTab(segments: string[]): string | undefined { } /** - * Hook to handle TV back/menu button presses. - * - * Behavior: - * - On home tab at root: allows app to exit (default tvOS behavior) - * - On other tabs at root: navigates to home tab - * - Deeper in navigation stack: goes back + * Keeps tvOS menu key interception disabled on the home tab root so the system + * can apply its native app-exit behavior. Other routes can opt into + * interception when they need JS-owned back handling. */ -export function useTVBackHandler() { - const navigation = useNavigation(); +export function useTVHomeBackHandler() { const segments = useSegments(); - const lastMenuKeyState = useRef(null); // Get current state const currentTab = getCurrentTab(segments); const atTabRoot = isAtTabRoot(segments); const isOnHomeRoot = atTabRoot && currentTab === "(home)"; - // Toggle menu key interception based on current location - useEffect(() => { - if (!Platform.isTV || !TVEventControl) return; - - if (isOnHomeRoot) { - // On home tab root - disable interception to allow app exit - if (lastMenuKeyState.current !== false) { - TVEventControl.disableTVMenuKey(); - lastMenuKeyState.current = false; - } - } else { - // On other screens - enable interception to handle navigation - if (lastMenuKeyState.current !== true) { - TVEventControl.enableTVMenuKey(); - lastMenuKeyState.current = true; - } - } - }, [isOnHomeRoot]); - - // Handle TV remote menu/back button events - useTVEventHandler((evt) => { - if (!evt) return; - if (evt.eventType === "menu" || evt.eventType === "back") { - // If on home root, let the default behavior happen (app exit) - if (isOnHomeRoot) { - return; - } - - // If at tab root level (but not home), navigate to home - if (atTabRoot) { - router.navigate("/(auth)/(tabs)/(home)"); - return; - } - - // Not at tab root - go back in the stack - if (navigation.canGoBack()) { - navigation.goBack(); - return; - } - - // Fallback: navigate to home - router.navigate("/(auth)/(tabs)/(home)"); - } - }); - - // Android TV BackHandler useEffect(() => { if (!Platform.isTV) return; - const handleBackPress = () => { - // If on home root, allow app to exit - if (isOnHomeRoot) { - return false; // Don't prevent default (allows exit) - } + if (isOnHomeRoot) { + disableTVMenuKeyInterception(); + return; + } - if (atTabRoot) { - router.navigate("/(auth)/(tabs)/(home)"); - return true; - } - - if (navigation.canGoBack()) { - navigation.goBack(); - return true; - } - - router.navigate("/(auth)/(tabs)/(home)"); - return true; - }; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - handleBackPress, - ); - - return () => subscription.remove(); - }, [navigation, isOnHomeRoot, atTabRoot]); -} - -/** - * Call this at app startup to enable TV menu key interception. - */ -export function enableTVMenuKeyInterception() { - if (Platform.isTV && TVEventControl) { - TVEventControl.enableTVMenuKey(); - } + enableTVMenuKeyInterception(); + }, [isOnHomeRoot]); } diff --git a/hooks/useTVBackPress.ts b/hooks/useTVBackPress.ts new file mode 100644 index 000000000..18a114073 --- /dev/null +++ b/hooks/useTVBackPress.ts @@ -0,0 +1,57 @@ +import { type DependencyList, useEffect } from "react"; +import { BackHandler, Platform } from "react-native"; + +type TVBackPressHandler = () => boolean | null | undefined; + +let TVEventControl: { + enableTVMenuKey: () => void; + disableTVMenuKey: () => void; +} | null = null; + +if (Platform.isTV) { + try { + TVEventControl = require("react-native").TVEventControl; + } catch { + TVEventControl = null; + } +} + +export function enableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.enableTVMenuKey(); + } +} + +export function disableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.disableTVMenuKey(); + } +} + +/** + * Subscribe to TV back presses through React Native's BackHandler. + * + * On Android TV this handles the hardware back button. On tvOS, + * react-native-tvos maps the Apple TV menu button to the same API when menu key + * interception is enabled. + * + * @see https://reactnative.dev/docs/backhandler + */ +export function useTVBackPress( + handler: TVBackPressHandler, + deps: DependencyList, +) { + useEffect(() => { + if (!Platform.isTV) return; + + // BackHandler is the shared back/menu surface for TV platforms: + // Android TV sends hardware back here, and react-native-tvos sends menu + // here when menu key interception is enabled. + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handler, + ); + + return () => subscription.remove(); + }, deps); +} diff --git a/hooks/useTVEventHandler.ts b/hooks/useTVEventHandler.ts new file mode 100644 index 000000000..d92011b75 --- /dev/null +++ b/hooks/useTVEventHandler.ts @@ -0,0 +1,17 @@ +import type { HWEvent } from "react-native"; +import { Platform } from "react-native"; + +type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void; + +let tvEventHandler: UseTVEventHandler = () => {}; + +if (Platform.isTV) { + try { + tvEventHandler = require("react-native") + .useTVEventHandler as UseTVEventHandler; + } catch { + tvEventHandler = () => {}; + } +} + +export const useTVEventHandler = tvEventHandler; From a1c98f9285855fd7ffc35b74116ee6349e3ffa3c Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Wed, 20 May 2026 11:53:01 +0100 Subject: [PATCH 195/309] fix: tv overlay focus navigation (#1558) --- components/tv/TVNextEpisodeCountdown.tsx | 24 ++++++++++++++- components/tv/TVSkipSegmentCard.tsx | 30 +++++++++++++++++-- .../video-player/controls/Controls.tv.tsx | 21 +++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 72440f97c..654cdfc64 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -7,7 +7,9 @@ import { Image, Pressable, Animated as RNAnimated, + type View as RNView, StyleSheet, + TVFocusGuideView, View, } from "react-native"; import Animated, { @@ -33,6 +35,12 @@ export interface TVNextEpisodeCountdownProps { onPlayNext?: () => void; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: RNView | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: RNView | null; } // Position constants @@ -47,6 +55,9 @@ export const TVNextEpisodeCountdown: FC = ({ onFinish, onPlayNext, controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, }) => { const typography = useScaledTVTypography(); const { t } = useTranslation(); @@ -135,10 +146,11 @@ export const TVNextEpisodeCountdown: FC = ({ pointerEvents='box-none' > @@ -172,6 +184,12 @@ export const TVNextEpisodeCountdown: FC = ({ + {downDestination && ( + + )} ); }; @@ -235,4 +253,8 @@ const createStyles = (typography: ReturnType) => backgroundColor: "#fff", borderRadius: 2, }, + returnFocusGuide: { + height: 1, + width: "100%", + }, }); diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index 3e53d0f01..bbea61b0e 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -2,7 +2,13 @@ import { Ionicons } from "@expo/vector-icons"; import type { FC } from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + TVFocusGuideView, + type View, +} from "react-native"; import Animated, { Easing, useAnimatedStyle, @@ -18,6 +24,12 @@ export interface TVSkipSegmentCardProps { type: "intro" | "credits"; /** Whether controls are visible - affects card position */ controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: View | null; } // Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive) @@ -29,6 +41,9 @@ export const TVSkipSegmentCard: FC = ({ onPress, type, controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, }) => { const { t } = useTranslation(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -67,10 +82,11 @@ export const TVSkipSegmentCard: FC = ({ pointerEvents='box-none' > = ({ {labelText} + {downDestination && ( + + )} ); }; @@ -114,4 +136,8 @@ const styles = StyleSheet.create({ color: "#fff", fontWeight: "600", }, + returnFocusGuide: { + height: 1, + width: "100%", + }, }); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b0e1a8861..f980a0eb7 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -268,6 +268,8 @@ export const Controls: FC = ({ const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); const [playButtonRef, setPlayButtonRef] = useState(null); const [progressBarRef, setProgressBarRef] = useState(null); + const [skipSegmentRef, setSkipSegmentRef] = useState(null); + const [nextEpisodeRef, setNextEpisodeRef] = useState(null); // Minimal seek bar state (shows only progress bar when seeking while controls hidden) const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false); @@ -1014,6 +1016,8 @@ export const Controls: FC = ({ goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); + const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef; + return ( = ({ onPress={skipIntro} type='intro' controlsVisible={showControls} + refSetter={setSkipSegmentRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> {/* Skip credits card - show when there's content after credits, OR no next episode */} @@ -1052,6 +1059,9 @@ export const Controls: FC = ({ onPress={skipCredit} type='credits' controlsVisible={showControls} + refSetter={setSkipSegmentRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> {nextItem && ( @@ -1063,6 +1073,9 @@ export const Controls: FC = ({ onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} controlsVisible={showControls} + refSetter={setNextEpisodeRef} + hasTVPreferredFocus={!showControls} + playButtonRef={showControls ? playButtonRef : null} /> )} @@ -1210,6 +1223,14 @@ export const Controls: FC = ({ )} + {/* Upward: control buttons → visible skip segment or next episode card */} + {topOverlayFocusTarget && ( + + )} + Date: Wed, 20 May 2026 12:54:50 +0200 Subject: [PATCH 196/309] chore(deps): Update github/codeql-action action to v4.35.5 (#1508) --- .github/workflows/ci-codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index 29330ac7d..e2b52fcf1 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 🏁 Initialize CodeQL - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: 🛠️ Autobuild - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 - name: 🧪 Perform CodeQL Analysis - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 From 55776d887f14adfcdf8773b6c7a100470f874399 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 13:17:20 +0200 Subject: [PATCH 197/309] chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1498) --- .github/workflows/build-apps.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 492c0a636..e580846d8 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -216,7 +216,7 @@ jobs: run: bun run prebuild - name: 🔧 Setup Xcode - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: xcode-version: "26.2" @@ -280,7 +280,7 @@ jobs: run: bun run prebuild - name: 🔧 Setup Xcode - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: xcode-version: "26.2" From 8df61838d4c64e031e44d8bb26d3115b21972cbe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 13:43:17 +0200 Subject: [PATCH 198/309] chore(deps): Update actions/cache action to v5.0.5 (#1429) --- .github/workflows/build-apps.yml | 16 ++++++++-------- .github/workflows/check-lockfile.yml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index e580846d8..324b82c0a 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -46,7 +46,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -60,7 +60,7 @@ jobs: bun run submodule-reload - name: 💾 Cache Gradle global - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.gradle/caches @@ -73,7 +73,7 @@ jobs: run: bun run prebuild - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} @@ -129,7 +129,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -143,7 +143,7 @@ jobs: bun run submodule-reload - name: 💾 Cache Gradle global - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.gradle/caches @@ -156,7 +156,7 @@ jobs: run: bun run prebuild:tv - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} @@ -200,7 +200,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} @@ -264,7 +264,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index 99a2a490d..ae4c0fe02 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -32,7 +32,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.bun/install/cache From 7bccafc476405c3e344963392d70ab1e0eb78ecc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 14:49:24 +0200 Subject: [PATCH 199/309] chore(deps): Update actions/dependency-review-action action to v4.8.3 (#1499) --- .github/workflows/linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 45ee39f35..bfc3a9a95 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 with: fail-on-severity: high base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} From 023bd15ca2c0aa0ff95a01d6a5fd549b6915b1f6 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 20 May 2026 15:04:15 +0200 Subject: [PATCH 200/309] chore: cleanup Cleaning up some old console logs Fixed the collection view to include seasons to align with the server view Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- .../collections/[collectionId].tsx | 2 +- components/home/Home.tv.tsx | 19 ------------------- components/login/TVLogin.tsx | 13 +++++++------ utils/pairingService.ts | 12 +++++++----- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index fe44932ba..5fd125c96 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -186,7 +186,7 @@ const page: React.FC = () => { genres: selectedGenres, tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), - includeItemTypes: ["Movie", "Series"], + includeItemTypes: ["Movie", "Series", "Season"], }); return response.data || null; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 4fe3de6c4..f2318db8c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -20,10 +20,7 @@ import { useTranslation } from "react-i18next"; import { ActivityIndicator, Animated, - Dimensions, Easing, - PixelRatio, - Platform, ScrollView, View, } from "react-native"; @@ -83,22 +80,6 @@ export const Home = () => { const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); - // Log TV viewport dimensions for DPI scaling debug - useEffect(() => { - const w = Dimensions.get("window"); - const s = Dimensions.get("screen"); - console.log("========== TV DIMENSIONS =========="); - console.log("Platform.OS:", Platform.OS, "isTV:", Platform.isTV); - console.log("Window:", w.width, "x", w.height); - console.log("Screen:", s.width, "x", s.height); - console.log("PixelRatio:", PixelRatio.get()); - console.log( - "scaleSize(210):", - 210 * Math.min(w.width / 1920, w.height / 1080), - ); - console.log("===================================="); - }, []); - // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); const debounceTimerRef = useRef | null>(null); diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 32e7e56e0..e596f70dd 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -246,7 +246,7 @@ export const TVLogin: React.FC = () => { setCurrentScreen("user-selection"); } } catch (error) { - console.error("[TVLogin] Error in handleConnect:", error); + if (__DEV__) console.error("[TVLogin] Error in handleConnect:", error); } }, [checkUrl, setServer, serverName, setSelectedTVServer], @@ -485,10 +485,11 @@ export const TVLogin: React.FC = () => { }); } } catch (saveError) { - console.error( - "[TVLogin] Failed to save pairing credential:", - saveError, - ); + if (__DEV__) + console.error( + "[TVLogin] Failed to save pairing credential:", + saveError, + ); } } catch (error) { const message = @@ -589,7 +590,7 @@ export const TVLogin: React.FC = () => { pairingCode, handlePairingCredentials, (error) => { - console.error("[TVLogin] Pairing error:", error); + if (__DEV__) console.error("[TVLogin] Pairing error:", error); setShowPairingQR(false); Alert.alert(t("login.error_title"), t("companion_login.error_generic")); }, diff --git a/utils/pairingService.ts b/utils/pairingService.ts index 755661196..d666f6b6a 100644 --- a/utils/pairingService.ts +++ b/utils/pairingService.ts @@ -27,13 +27,14 @@ export function startPairingListener( }); socket.on("error", (err) => { - console.error("[PairingService] Socket error:", err); + if (__DEV__) console.error("[PairingService] Socket error:", err); onError?.(err.message); cleanup(); }); socket.bind(PAIRING_PORT, () => { - console.log("[PairingService] Listening on port", PAIRING_PORT); + if (__DEV__) + console.log("[PairingService] Listening on port", PAIRING_PORT); }); socket.on("message", (msg) => { @@ -46,11 +47,11 @@ export function startPairingListener( if (data.code !== code) return; if (!data.server_url || !data.username || !data.password) { - console.error("[PairingService] Missing fields in pairing response"); + if (__DEV__) + console.error("[PairingService] Missing fields in pairing response"); return; } - console.log("[PairingService] Credentials received"); active = false; onCredentialsReceived({ serverUrl: data.server_url, @@ -59,7 +60,8 @@ export function startPairingListener( }); cleanup(); } catch (error) { - console.error("[PairingService] Error parsing message:", error); + if (__DEV__) + console.error("[PairingService] Error parsing message:", error); } }); From 52bc5e912dd426936e2cfff7e2d9fc35df3a43f7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:28:34 +0200 Subject: [PATCH 201/309] chore(deps): Update actions/dependency-review-action action to v4.9.0 (#1502) --- .github/workflows/linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index bfc3a9a95..b8695bf93 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: fail-on-severity: high base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} From e84cea64270db764a5bd8b1044a342aa35e9fd4e Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 20 May 2026 15:29:24 +0200 Subject: [PATCH 202/309] chore: cleanup (#1565) --- .../collections/[collectionId].tsx | 2 +- components/home/Home.tv.tsx | 19 ------------------- components/login/TVLogin.tsx | 13 +++++++------ utils/pairingService.ts | 12 +++++++----- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index fe44932ba..5fd125c96 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -186,7 +186,7 @@ const page: React.FC = () => { genres: selectedGenres, tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), - includeItemTypes: ["Movie", "Series"], + includeItemTypes: ["Movie", "Series", "Season"], }); return response.data || null; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 4fe3de6c4..f2318db8c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -20,10 +20,7 @@ import { useTranslation } from "react-i18next"; import { ActivityIndicator, Animated, - Dimensions, Easing, - PixelRatio, - Platform, ScrollView, View, } from "react-native"; @@ -83,22 +80,6 @@ export const Home = () => { const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); - // Log TV viewport dimensions for DPI scaling debug - useEffect(() => { - const w = Dimensions.get("window"); - const s = Dimensions.get("screen"); - console.log("========== TV DIMENSIONS =========="); - console.log("Platform.OS:", Platform.OS, "isTV:", Platform.isTV); - console.log("Window:", w.width, "x", w.height); - console.log("Screen:", s.width, "x", s.height); - console.log("PixelRatio:", PixelRatio.get()); - console.log( - "scaleSize(210):", - 210 * Math.min(w.width / 1920, w.height / 1080), - ); - console.log("===================================="); - }, []); - // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); const debounceTimerRef = useRef | null>(null); diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 32e7e56e0..e596f70dd 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -246,7 +246,7 @@ export const TVLogin: React.FC = () => { setCurrentScreen("user-selection"); } } catch (error) { - console.error("[TVLogin] Error in handleConnect:", error); + if (__DEV__) console.error("[TVLogin] Error in handleConnect:", error); } }, [checkUrl, setServer, serverName, setSelectedTVServer], @@ -485,10 +485,11 @@ export const TVLogin: React.FC = () => { }); } } catch (saveError) { - console.error( - "[TVLogin] Failed to save pairing credential:", - saveError, - ); + if (__DEV__) + console.error( + "[TVLogin] Failed to save pairing credential:", + saveError, + ); } } catch (error) { const message = @@ -589,7 +590,7 @@ export const TVLogin: React.FC = () => { pairingCode, handlePairingCredentials, (error) => { - console.error("[TVLogin] Pairing error:", error); + if (__DEV__) console.error("[TVLogin] Pairing error:", error); setShowPairingQR(false); Alert.alert(t("login.error_title"), t("companion_login.error_generic")); }, diff --git a/utils/pairingService.ts b/utils/pairingService.ts index 755661196..d666f6b6a 100644 --- a/utils/pairingService.ts +++ b/utils/pairingService.ts @@ -27,13 +27,14 @@ export function startPairingListener( }); socket.on("error", (err) => { - console.error("[PairingService] Socket error:", err); + if (__DEV__) console.error("[PairingService] Socket error:", err); onError?.(err.message); cleanup(); }); socket.bind(PAIRING_PORT, () => { - console.log("[PairingService] Listening on port", PAIRING_PORT); + if (__DEV__) + console.log("[PairingService] Listening on port", PAIRING_PORT); }); socket.on("message", (msg) => { @@ -46,11 +47,11 @@ export function startPairingListener( if (data.code !== code) return; if (!data.server_url || !data.username || !data.password) { - console.error("[PairingService] Missing fields in pairing response"); + if (__DEV__) + console.error("[PairingService] Missing fields in pairing response"); return; } - console.log("[PairingService] Credentials received"); active = false; onCredentialsReceived({ serverUrl: data.server_url, @@ -59,7 +60,8 @@ export function startPairingListener( }); cleanup(); } catch (error) { - console.error("[PairingService] Error parsing message:", error); + if (__DEV__) + console.error("[PairingService] Error parsing message:", error); } }); From 4bef386b82a6c06c3244982fc47240a2e90eb226 Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Wed, 20 May 2026 14:30:40 +0100 Subject: [PATCH 203/309] fix(tvOS): Patches for udp and screens menu button handling (#1564) --- bun-patches/react-native-screens@4.18.0.patch | 87 ++++++++++++ bun-patches/react-native-udp@4.1.7.patch | 17 +++ bun.lock | 4 + components/companion/CompanionLoginScreen.tsx | 33 ++++- components/login/TVAddServerForm.tsx | 131 +++--------------- components/login/TVAddUserForm.tsx | 105 ++------------ components/login/TVLogin.tsx | 3 + components/login/TVQRCodeDisplay.tsx | 104 ++------------ components/login/TVUserSelectionScreen.tsx | 36 ++--- hooks/useTVBackPress.ts | 17 ++- package.json | 4 + 11 files changed, 210 insertions(+), 331 deletions(-) create mode 100644 bun-patches/react-native-screens@4.18.0.patch create mode 100644 bun-patches/react-native-udp@4.1.7.patch diff --git a/bun-patches/react-native-screens@4.18.0.patch b/bun-patches/react-native-screens@4.18.0.patch new file mode 100644 index 000000000..7213178de --- /dev/null +++ b/bun-patches/react-native-screens@4.18.0.patch @@ -0,0 +1,87 @@ +diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm +index 47a671928338ae7fb4f85532d9fd1ed2d594f823..e4eecb7d5f9d3c3afc8a090fb953010e4e1b8a08 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 ++#import ++#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, +@@ -61,6 +72,57 @@ + @end + + @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/bun-patches/react-native-udp@4.1.7.patch b/bun-patches/react-native-udp@4.1.7.patch new file mode 100644 index 000000000..823acb86e --- /dev/null +++ b/bun-patches/react-native-udp@4.1.7.patch @@ -0,0 +1,17 @@ +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 +index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 +--- a/react-native-udp.podspec ++++ b/react-native-udp.podspec +@@ -9,7 +9,8 @@ Pod::Spec.new do |s| + s.homepage = package_json["homepage"] + s.license = package_json["license"] + s.author = { package_json["author"] => package_json["author"] } +- s.platform = :ios, "7.0" ++ s.ios.deployment_target = "7.0" ++ s.tvos.deployment_target = "15.1" + s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" } + s.source_files = 'ios/**/*.{h,m}' + s.dependency 'React-Core' diff --git a/bun.lock b/bun.lock index ab9acf3e4..3d58370b6 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,10 @@ }, }, }, + "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", + }, "overrides": { "expo-constants": "18.0.13", }, diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx index 8ca1490ee..95e92dffb 100644 --- a/components/companion/CompanionLoginScreen.tsx +++ b/components/companion/CompanionLoginScreen.tsx @@ -1,4 +1,3 @@ -import { Camera, CameraView } from "expo-camera"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -30,13 +29,21 @@ interface ParsedPairingCode { code: string; } +type ExpoCameraModule = typeof import("expo-camera"); + +const ExpoCamera: ExpoCameraModule | null = Platform.isTV + ? null + : require("expo-camera"); + export const CompanionLoginScreen: React.FC = () => { const { t } = useTranslation(); const router = useRouter(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [screenState, setScreenState] = useState("scanning"); + const [screenState, setScreenState] = useState( + Platform.isTV ? "form" : "scanning", + ); const [pairingCode, setPairingCode] = useState(""); const [serverUrl, setServerUrl] = useState(""); const [username, setUsername] = useState(""); @@ -56,9 +63,11 @@ export const CompanionLoginScreen: React.FC = () => { // Request camera permission useEffect(() => { - Camera.getCameraPermissionsAsync().then((response) => { + if (!ExpoCamera) return; + + ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => { if (!response.granted) { - Camera.requestCameraPermissionsAsync().then((result) => { + ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => { if (!result.granted) { setScreenState("no-permission"); } @@ -465,6 +474,22 @@ export const CompanionLoginScreen: React.FC = () => { ); } + const CameraView = ExpoCamera?.CameraView; + + if (!CameraView) { + return ( + + + + ); + } + return ( {/* Camera full screen */} diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx index ab74c55a3..de28c64ef 100644 --- a/components/login/TVAddServerForm.tsx +++ b/components/login/TVAddServerForm.tsx @@ -1,18 +1,10 @@ -import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useEffect, useRef, useState } from "react"; -import { - Animated, - BackHandler, - Easing, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; +import React, { useCallback, useState } from "react"; +import { ScrollView, View } from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; import { TVInput } from "./TVInput"; interface TVAddServerFormProps { @@ -23,68 +15,6 @@ interface TVAddServerFormProps { disabled?: boolean; } -const TVBackButton: React.FC<{ - onPress: () => void; - label: string; - disabled?: boolean; -}> = ({ onPress, label, disabled = false }) => { - const [isFocused, setIsFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateFocus = (focused: boolean) => { - Animated.timing(scale, { - toValue: focused ? 1.05 : 1, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }; - - return ( - { - setIsFocused(true); - animateFocus(true); - }} - onBlur={() => { - setIsFocused(false); - animateFocus(false); - }} - style={{ alignSelf: "flex-start", marginBottom: 24 }} - disabled={disabled} - focusable={!disabled} - > - - - - {label} - - - - ); -}; - export const TVAddServerForm: React.FC = ({ onConnect, onStartPairing, @@ -103,23 +33,13 @@ export const TVAddServerForm: React.FC = ({ const isDisabled = disabled || loading; - // Handle Android TV back button, needed as an "override" - useEffect(() => { - if (!Platform.isTV) return; + const handleBack = useCallback(() => { + if (isDisabled) return false; + onBack(); + return true; + }, [isDisabled, onBack]); - const handleBackPress = () => { - if (disabled) return false; - onBack(); - return true; - }; - - const subscription = BackHandler.addEventListener( - "hardwareBackPress", - handleBackPress, - ); - - return () => subscription.remove(); - }, [onBack, disabled]); + useTVBackPress(() => handleBack(), [handleBack]); return ( = ({ paddingHorizontal: 60, }} > - {/* Back Button */} - + {/* Title */} + + {t("server.enter_url_to_jellyfin_server")} + {/* Server URL Input */} @@ -173,21 +100,9 @@ export const TVAddServerForm: React.FC = ({ - {/* Hint text */} - - {t("server.enter_url_to_jellyfin_server")} - - {/* Pair with Phone */} {onStartPairing && ( - + , + />, ); }); } @@ -299,17 +359,16 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); @@ -318,21 +377,20 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); return items; })} - - - + + + ); } diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 2e844c881..443d1e148 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -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 { FilterButton } from "@/components/filters/FilterButton"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; @@ -43,38 +50,37 @@ export const DiscoverFilters: React.FC = ({ - t(`home.settings.plugins.jellyseerr.order_by.${item}`), - )} - 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, - ); + selection={jellyseerrOrderBy as unknown as string} + onSelectionChange={(value) => { + setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort); }} - /> + > + {sortOptions.map((item) => ( + + {t(`home.settings.plugins.jellyseerr.order_by.${item}`)} + + ))} + t(`library.filters.${item}`))} - variant='menu' - selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - setJellyseerrSortOrder(orderOptions[index]); + selection={jellyseerrSortOrder} + onSelectionChange={(value) => { + setJellyseerrSortOrder(value as "asc" | "desc"); }} - /> + > + {orderOptions.map((item) => ( + + {t(`library.filters.${item}`)} + + ))} + diff --git a/components/search/SearchTabButtons.tsx b/components/search/SearchTabButtons.tsx index b312b82e4..55510e52e 100644 --- a/components/search/SearchTabButtons.tsx +++ b/components/search/SearchTabButtons.tsx @@ -1,5 +1,7 @@ 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"; type SearchType = "Library" | "Discover"; @@ -28,10 +30,14 @@ export const SearchTabButtons: React.FC = ({ }} > = ({ }} > diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index 9891ea0c2..2b1d6125c 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -123,7 +123,9 @@ export const HeaderControls: FC = ({ - {!Platform.isTV && ( + {/* Rotate toggle is Android-only: iOS does not reliably rotate the + player back to portrait programmatically. */} + {Platform.OS === "android" && ( { if (cancelled || step >= steps) { if (!cancelled) { - sound.setVolumeAsync(to).catch(() => {}); + player.volume = to; } resolve(); return; } step++; current += delta; - sound - .setVolumeAsync(Math.max(0, Math.min(1, current))) - .catch(() => {}) - .then(() => { - if (!cancelled) { - setTimeout(tick, FADE_STEP_MS); - } else { - resolve(); - } - }); + player.volume = Math.max(0, Math.min(1, current)); + if (!cancelled) { + setTimeout(tick, FADE_STEP_MS); + } else { + resolve(); + } }; tick(); @@ -64,41 +63,35 @@ function fadeVolume( } // --- Module-level singleton state --- -let sharedSound: AudioType.Sound | null = null; +let sharedPlayer: AudioPlayer | null = null; let currentSongId: string | null = null; let ownerCount = 0; let activeFade: { cancel: () => void } | null = null; let cleanupPromise: Promise | null = null; -/** Fade out, stop, and unload the shared sound. */ -async function teardownSharedSound(): Promise { - const sound = sharedSound; - if (!sound) return; +/** Fade out, stop, and release the shared player. */ +async function teardownSharedPlayer(): Promise { + const player = sharedPlayer; + if (!player) return; activeFade?.cancel(); activeFade = null; try { - const status = await sound.getStatusAsync(); - if (status.isLoaded) { - const currentVolume = status.volume ?? TARGET_VOLUME; - const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION); + if (player.isLoaded) { + const currentVolume = player.volume ?? TARGET_VOLUME; + const fade = fadeVolume(player, currentVolume, 0, FADE_OUT_DURATION); activeFade = fade; await fade.promise; activeFade = null; - await sound.stopAsync(); - await sound.unloadAsync(); + player.pause(); } } catch { - try { - await sound.unloadAsync(); - } catch { - // ignore - } + // ignore } - if (sharedSound === sound) { - sharedSound = null; + if (sharedPlayer === player) { + sharedPlayer = null; currentSongId = null; } } @@ -106,7 +99,7 @@ async function teardownSharedSound(): Promise { /** Begin cleanup idempotently; returns the shared promise. */ function beginCleanup(): Promise { if (!cleanupPromise) { - cleanupPromise = teardownSharedSound().finally(() => { + cleanupPromise = teardownSharedPlayer().finally(() => { cleanupPromise = null; }); } @@ -154,12 +147,12 @@ export function useTVThemeMusic(itemId: string | undefined) { const startPlayback = async () => { // If the same song is already playing, keep it going - if (currentSongId === songId && sharedSound) { + if (currentSongId === songId && sharedPlayer) { return; } // If a different song is playing (or cleanup is in progress), tear it down first - if (sharedSound || cleanupPromise) { + if (sharedPlayer || cleanupPromise) { activeFade?.cancel(); activeFade = null; await beginCleanup(); @@ -167,14 +160,14 @@ export function useTVThemeMusic(itemId: string | undefined) { if (!mounted) return; - const sound = new Audio.Sound(); - sharedSound = sound; + const player = createAudioPlayer(null); + sharedPlayer = player; currentSongId = songId; try { - await Audio.setAudioModeAsync({ - playsInSilentModeIOS: true, - staysActiveInBackground: false, + await setAudioModeAsync({ + playsInSilentMode: true, + shouldPlayInBackground: false, }); const params = new URLSearchParams({ @@ -190,19 +183,19 @@ export function useTVThemeMusic(itemId: string | undefined) { EnableRemoteMedia: "false", }); const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; - await sound.loadAsync({ uri: url }); + player.replace({ uri: url }); - if (!mounted || sharedSound !== sound) { - await sound.unloadAsync(); + if (!mounted || sharedPlayer !== player) { + player.pause(); return; } - await sound.setIsLoopingAsync(true); - await sound.setVolumeAsync(0); - await sound.playAsync(); + player.loop = true; + player.volume = 0; + player.play(); - if (mounted && sharedSound === sound) { - const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION); + if (mounted && sharedPlayer === player) { + const fade = fadeVolume(player, 0, TARGET_VOLUME, FADE_IN_DURATION); activeFade = fade; await fade.promise; activeFade = null; diff --git a/package.json b/package.json index 4771993d8..00d37d6ee 100644 --- a/package.json +++ b/package.json @@ -25,71 +25,72 @@ "postinstall": "patch-package" }, "dependencies": { - "@bottom-tabs/react-navigation": "1.1.0", + "@bottom-tabs/react-navigation": "1.2.0", "@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/ui": "0.2.0-beta.9", + "@expo/ui": "~55.0.17", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@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/native": "^7.0.14", + "@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/react-pacer": "^0.19.1", "@tanstack/react-query": "5.90.17", "@tanstack/react-query-persist-client": "^5.90.18", "axios": "^1.7.9", - "expo": "~54.0.31", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.12", - "expo-av": "^16.0.8", - "expo-background-task": "~1.0.10", - "expo-blur": "~15.0.8", - "expo-brightness": "~14.0.8", - "expo-build-properties": "~1.0.10", - "expo-camera": "^55.0.18", - "expo-constants": "18.0.13", - "expo-crypto": "^15.0.8", - "expo-dev-client": "~6.0.20", - "expo-device": "~8.0.10", - "expo-font": "~14.0.10", - "expo-haptics": "~15.0.8", - "expo-image": "~3.0.11", - "expo-linear-gradient": "~15.0.8", - "expo-linking": "~8.0.11", - "expo-localization": "~17.0.8", - "expo-location": "^19.0.8", - "expo-notifications": "~0.32.16", - "expo-router": "~6.0.21", - "expo-screen-orientation": "~9.0.8", - "expo-secure-store": "^15.0.8", - "expo-sharing": "~14.0.8", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "~6.0.9", - "expo-task-manager": "14.0.9", - "expo-web-browser": "~15.0.10", + "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.21", "nativewind": "^2.0.11", "patch-package": "^8.0.0", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", "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-bottom-tabs": "1.1.0", + "react-native-bottom-tabs": "1.2.0", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-country-flag": "^2.0.2", "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.28.0", + "react-native-gesture-handler": "~2.30.0", "react-native-glass-effect-view": "^1.0.0", "react-native-google-cast": "^4.9.1", "react-native-image-colors": "^2.4.0", @@ -97,13 +98,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": "^6.9.1", + "react-native-pager-view": "8.0.0", "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-safe-area-context": "~5.6.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-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-udp": "^4.1.7", @@ -111,7 +112,7 @@ "react-native-uuid": "^2.0.3", "react-native-volume-manager": "^2.0.8", "react-native-web": "^0.21.0", - "react-native-worklets": "0.5.1", + "react-native-worklets": "0.7.4", "sonner-native": "0.21.2", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", @@ -124,7 +125,7 @@ "@react-native-tvos/config-tv": "0.1.4", "@types/jest": "29.5.14", "@types/lodash": "4.17.23", - "@types/react": "19.1.17", + "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", "expo-doctor": "1.17.14", @@ -164,9 +165,8 @@ ], "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" - }, - "resolutions": { - "expo-constants": "18.0.13" + "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": "patches/react-native-bottom-tabs@1.2.0.patch" } } diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.js index 2b660f702..97d11b9de 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.js @@ -24,6 +24,34 @@ function buildPatch() { " t.build_configurations.each do |cfg|", " cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'", " 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", PATCH_END, From cf91c4c682cfaf621f39e791ba7b4c2e5434377f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 22:44:18 +0200 Subject: [PATCH 255/309] chore(deps): Update dependency @react-native-community/netinfo to v12 (#1457) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 689a88ef3..b1202b9c5 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", - "@react-native-community/netinfo": "^11.4.1", + "@react-native-community/netinfo": "^12.0.0", "@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", @@ -534,7 +534,7 @@ "@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.3", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw=="], - "@react-native-community/netinfo": ["@react-native-community/netinfo@11.5.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-/g0m65BtX9HU+bPiCH2517bOHpEIUsGrWFXDzi1a5nNKn5KujQgm04WhL7/OSXWKHyrT8VVtUoJA0XKRxueBpQ=="], + "@react-native-community/netinfo": ["@react-native-community/netinfo@12.0.1", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ=="], "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="], diff --git a/package.json b/package.json index a59633847..fa265c81d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", - "@react-native-community/netinfo": "^11.4.1", + "@react-native-community/netinfo": "^12.0.0", "@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", From 63adb985409dba5d9423ca787eb33b893f97665a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 28 May 2026 11:22:51 +0200 Subject: [PATCH 256/309] fix(mpv-player): add missing closing brace to configureAudioSession --- modules/mpv-player/ios/MpvPlayerView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index c8ffc8315..6ac1f8612 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -154,6 +154,7 @@ class MpvPlayerView: ExpoView { } catch { print("Failed to configure audio session: \(error)") } + } // MARK: - Audio Session & Notifications private func setupNotifications() { From 3379cedc01e1716516b6d7fb0449870286d841e4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 28 May 2026 11:26:48 +0200 Subject: [PATCH 257/309] fix(mpv-player): split combined delegate method back into separate HDR detection and audio output selection callbacks --- modules/mpv-player/ios/MPVLayerRenderer.swift | 3 ++- modules/mpv-player/ios/MpvPlayerView.swift | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 38cbbf617..8c43b9e4e 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -18,7 +18,8 @@ protocol MPVLayerRendererDelegate: AnyObject { func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) - func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double, didSelectAudioOutput audioOutput: String) + func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) + func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) } /// MPV player using vo_avfoundation for video output. diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 6ac1f8612..0b3158e76 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -512,11 +512,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate { self.onTracksReady([:]) } } - - func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double, didSelectAudioOutput audioOutput: String) { + func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) { #if os(tvOS) setDisplayCriteria(for: mode, fps: Float(fps)) #endif + } + + func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { // Audio output is now active - this is the right time to activate audio session and set Now Playing print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing") nowPlayingManager.activateAudioSession() From 70a00330942dd42c7908fb7a9258883ae8005e5d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 28 May 2026 11:41:51 +0200 Subject: [PATCH 258/309] chore: version --- app.json | 4 ++-- bun.lock | 2 +- eas.json | 8 ++++---- providers/JellyfinProvider.tsx | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app.json b/app.json index 7ef715bf9..7ed7a7fd6 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.52.0", + "version": "0.54.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -36,7 +36,7 @@ "appleTeamId": "MWD5K362T8" }, "android": { - "versionCode": 92, + "versionCode": 93, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/bun.lock b/bun.lock index 8a9e0b577..8db5508fa 100644 --- a/bun.lock +++ b/bun.lock @@ -1661,7 +1661,7 @@ "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], - "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="], + "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], diff --git a/eas.json b/eas.json index 0a17c22a9..03f933895 100644 --- a/eas.json +++ b/eas.json @@ -52,14 +52,14 @@ }, "production": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "buildType": "apk", "image": "latest" @@ -67,7 +67,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "buildType": "apk", "image": "latest" @@ -78,7 +78,7 @@ }, "production_tv": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "env": { "EXPO_TV": "1" }, diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index da58ef763..7dff4b366 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -53,7 +53,7 @@ const initialApi = (() => { const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); const jellyfinInstance = new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.52.0" }, + clientInfo: { name: "Streamyfin", version: "0.54.0" }, deviceInfo: { name: deviceName, id, @@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); return new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.52.0" }, + clientInfo: { name: "Streamyfin", version: "0.54.0" }, deviceInfo: { name: deviceName, id, @@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.52.0"`, + }, DeviceId="${deviceId}", Version="0.54.0"`, }; }, [deviceId]); From c12f252079c3ee81cbf13ca8eec54fe538ddaac1 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Thu, 28 May 2026 15:15:13 +0200 Subject: [PATCH 259/309] fix: sorted app languages Sorting the app languages by alphabetical order Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- i18n.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/i18n.ts b/i18n.ts index d462efdfd..0eb92a068 100644 --- a/i18n.ts +++ b/i18n.ts @@ -29,7 +29,7 @@ import vi from "./translations/vi.json"; import zhCN from "./translations/zh-CN.json"; import zhTW from "./translations/zh-TW.json"; -export const APP_LANGUAGES = [ +const _APP_LANGUAGES = [ { label: "Catalan", value: "ca" }, { label: "العربية", value: "ar" }, { label: "Dansk", value: "da" }, @@ -57,7 +57,9 @@ export const APP_LANGUAGES = [ { label: "简体中文", value: "zh-CN" }, { label: "繁體中文", value: "zh-TW" }, { label: "Tiếng Việt", value: "vi" }, -]; +].sort((a, b) => a.label.localeCompare(b.label)); + +export const APP_LANGUAGES = _APP_LANGUAGES; i18n.use(initReactI18next).init({ compatibilityJSON: "v4", From 5db4a79e8a4da7966c9c3c0885cdc93f69c30cea Mon Sep 17 00:00:00 2001 From: Gauvain Date: Thu, 28 May 2026 20:46:21 +0200 Subject: [PATCH 260/309] ci: enable unsigned tvOS build (signed stays gated on EAS tvOS credentials) --- .github/workflows/build-apps.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 6792fe56a..814ef1de1 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -366,9 +366,10 @@ 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. - if: false && (!contains(github.event.head_commit.message, '[skip ci]')) + # 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) permissions: From afe9d33ee49ab214d0ea70a961e545079cf1c862 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 28 May 2026 22:02:49 +0200 Subject: [PATCH 261/309] 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. --- plugins/withTVOSTopShelf.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.js index b37051348..56610fcf7 100644 --- a/plugins/withTVOSTopShelf.js +++ b/plugins/withTVOSTopShelf.js @@ -15,7 +15,7 @@ function getBundleIdentifier(config) { } function getAppGroupIdentifier(config) { - return `group.${getBundleIdentifier(config)}`; + return `group.${getBundleIdentifier(config)}.tvtopshelf`; } function getKeychainAccessGroupIdentifier(config) { @@ -83,7 +83,7 @@ const withTVOSTopShelf = (config) => { const keychainAccessGroupIdentifier = getKeychainAccessGroupIdentifier(config); const bundleIdentifier = getBundleIdentifier(config); - const extensionBundleIdentifier = `${bundleIdentifier}.TopShelf`; + const extensionBundleIdentifier = `${bundleIdentifier}.tvtopshelf`; const isTVBuild = process.env.EXPO_TV === "1"; if (isTVBuild) { From 38d638cdebed23a99900a4805292351197897c00 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Thu, 28 May 2026 23:56:03 +0200 Subject: [PATCH 262/309] 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). --- .github/renovate.json | 19 + .github/workflows/build-apps.yml | 12 +- app.json | 2 +- .../livetv/_layout.tsx | 6 +- .../(libraries)/music/[libraryId]/_layout.tsx | 6 +- .../(libraries)/music/[libraryId]/albums.tsx | 2 +- .../(libraries)/music/[libraryId]/artists.tsx | 2 +- .../music/[libraryId]/playlists.tsx | 2 +- .../music/[libraryId]/suggestions.tsx | 2 +- app/(auth)/(tabs)/_layout.tsx | 4 +- app/(auth)/tv-subtitle-modal.tsx | 2 +- app/_layout.tsx | 2 +- .../@react-native%2Fcodegen@0.83.6.patch | 29 - bun-patches/react-native-screens@4.18.0.patch | 191 ------ bun.lock | 649 +++++++----------- components/stacks/NestedTabPageStack.tsx | 11 +- components/tv/TVSubtitleResultCard.tsx | 2 +- .../video-player/controls/Controls.tv.tsx | 6 +- package.json | 92 ++- utils/useReactNavigationQuery.ts | 2 +- 20 files changed, 361 insertions(+), 682 deletions(-) delete mode 100644 bun-patches/@react-native%2Fcodegen@0.83.6.patch delete mode 100644 bun-patches/react-native-screens@4.18.0.patch diff --git a/.github/renovate.json b/.github/renovate.json index 364842311..fdbe3734d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -25,6 +25,25 @@ "osvVulnerabilityAlerts": true, "configMigration": true, "separateMinorPatch": true, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/\\.ya?ml$/"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s+xcode-version:\\s*[\"']?(?[^\"'\\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, diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 814ef1de1..fd68e23a1 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -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 @@ -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: @@ -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 @@ -408,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: diff --git a/app.json b/app.json index 7ed7a7fd6..8751d44c6 100644 --- a/app.json +++ b/app.json @@ -78,7 +78,7 @@ "expo-build-properties", { "ios": { - "deploymentTarget": "15.6", + "deploymentTarget": "16.4", "useFrameworks": "static" }, "android": { diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx index 28cc2f9da..f5bdc3e3b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx index 69daf9296..66f877d4a 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx index 3fb5305b2..10fffbe71 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx @@ -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"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx index e8191404c..f268e0b24 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx @@ -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"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx index a03f9c3db..85b4be5fd 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx @@ -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"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx index fb762862d..81c2272f8 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx @@ -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"; diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 29e706ad6..29d3748f1 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -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"; diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index c59952ba1..ed5d24d9c 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -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", diff --git a/app/_layout.tsx b/app/_layout.tsx index accdd7260..44992402d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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"; diff --git a/bun-patches/@react-native%2Fcodegen@0.83.6.patch b/bun-patches/@react-native%2Fcodegen@0.83.6.patch deleted file mode 100644 index 7448371e8..000000000 --- a/bun-patches/@react-native%2Fcodegen@0.83.6.patch +++ /dev/null @@ -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}) => `/** diff --git a/bun-patches/react-native-screens@4.18.0.patch b/bun-patches/react-native-screens@4.18.0.patch deleted file mode 100644 index 39b757ca4..000000000 --- a/bun-patches/react-native-screens@4.18.0.patch +++ /dev/null @@ -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 -+#import -+#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 - diff --git a/bun.lock b/bun.lock index 8db5508fa..f9195aeda 100644 --- a/bun.lock +++ b/bun.lock @@ -7,61 +7,58 @@ "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/react-pacer": "^0.19.1", "@tanstack/react-query": "5.90.20", "@tanstack/react-query-persist-client": "^5.90.18", "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", + "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": "^25.0.0", "jotai": "2.16.2", "lodash": "4.17.23", "nativewind": "^2.0.11", "patch-package": "^8.0.0", - "react": "19.2.0", - "react-dom": "19.2.0", + "react": "19.2.3", + "react-dom": "19.2.3", "react-i18next": "16.5.3", - "react-native": "npm:react-native-tvos@0.83.6-0", + "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", @@ -70,7 +67,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", @@ -78,13 +75,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", @@ -92,14 +89,14 @@ "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", }, "devDependencies": { - "@babel/core": "7.28.6", + "@babel/core": "7.29.7", "@biomejs/biome": "2.3.11", "@react-native-community/cli": "20.1.3", "@react-native-tvos/config-tv": "0.1.6", @@ -117,19 +114,19 @@ }, }, "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-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", - "@react-native/codegen@0.83.6": "bun-patches/@react-native%2Fcodegen@0.83.6.patch", }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], @@ -177,14 +174,6 @@ "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ=="], - "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], - - "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], - - "@babel/plugin-syntax-class-properties": ["@babel/plugin-syntax-class-properties@7.12.13", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA=="], - - "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="], - "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="], "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], @@ -193,30 +182,12 @@ "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], - "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="], - - "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="], - - "@babel/plugin-syntax-json-strings": ["@babel/plugin-syntax-json-strings@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@babel/plugin-syntax-logical-assignment-operators": ["@babel/plugin-syntax-logical-assignment-operators@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig=="], - "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], - "@babel/plugin-syntax-numeric-separator": ["@babel/plugin-syntax-numeric-separator@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug=="], - - "@babel/plugin-syntax-object-rest-spread": ["@babel/plugin-syntax-object-rest-spread@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA=="], - - "@babel/plugin-syntax-optional-catch-binding": ["@babel/plugin-syntax-optional-catch-binding@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q=="], - "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], - "@babel/plugin-syntax-private-property-in-object": ["@babel/plugin-syntax-private-property-in-object@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg=="], - - "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="], - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], @@ -233,8 +204,6 @@ "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="], - "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="], - "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], @@ -243,10 +212,6 @@ "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], - "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="], - - "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="], - "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], @@ -255,8 +220,6 @@ "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="], - "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="], - "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], @@ -287,18 +250,12 @@ "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="], - "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ=="], - - "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="], - "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="], "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="], - "@babel/preset-react": ["@babel/preset-react@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-transform-react-display-name": "^7.29.7", "@babel/plugin-transform-react-jsx": "^7.29.7", "@babel/plugin-transform-react-jsx-development": "^7.29.7", "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA=="], - "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], @@ -339,55 +296,61 @@ "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], - "@expo/cli": ["@expo/cli@55.0.32", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~55.0.17", "@expo/config-plugins": "~55.0.10", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.2", "@expo/image-utils": "^0.8.14", "@expo/json-file": "^10.0.15", "@expo/log-box": "55.0.12", "@expo/metro": "~55.1.1", "@expo/metro-config": "~55.0.23", "@expo/osascript": "^2.4.4", "@expo/package-manager": "^1.10.5", "@expo/plist": "^0.5.4", "@expo/prebuild-config": "^55.0.18", "@expo/require-utils": "^55.0.5", "@expo/router-server": "^55.0.18", "@expo/schema-utils": "^55.0.4", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.0", "@react-native/dev-middleware": "0.83.6", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^55.0.11", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.3", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-fq+/yUYBVw5ZudT4igNyJ3WaF17R39iS7EZlrkfHkLI7Y1kmUlivabwKviLoAfepJOKjKODKpViti9EPfmG3SQ=="], + "@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], - "@expo/config": ["@expo/config@55.0.17", "", { "dependencies": { "@expo/config-plugins": "~55.0.9", "@expo/config-types": "^55.0.5", "@expo/json-file": "^10.0.14", "@expo/require-utils": "^55.0.5", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-Y3VaRg7Jllg3MhlUOTQqHm6/dttsqcjYlnS9enhAllZvPUpTHnRA4YPETtUZlxkdMJy6y3UZe986pd/KfJ6OTg=="], + "@expo/config": ["@expo/config@56.0.9", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/json-file": "^10.2.0", "@expo/require-utils": "^56.1.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw=="], - "@expo/config-plugins": ["@expo/config-plugins@55.0.10", "", { "dependencies": { "@expo/config-types": "^55.0.5", "@expo/json-file": "~10.0.15", "@expo/plist": "^0.5.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-1txnRnMLIO5lM/Of/VyvDkCwZap0YFvCyfSTIlUQamhwhx6Rh7r8TXfcIstaDYUQ7X6GTMkNxLXWbcYS6ZAFDw=="], + "@expo/config-plugins": ["@expo/config-plugins@56.0.8", "", { "dependencies": { "@expo/config-types": "^56.0.5", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", "@expo/require-utils": "^56.1.3", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg=="], - "@expo/config-types": ["@expo/config-types@55.0.5", "", {}, "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg=="], + "@expo/config-types": ["@expo/config-types@56.0.5", "", {}, "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA=="], "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], - "@expo/devtools": ["@expo/devtools@55.0.3", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-KoIDgo0NoXeWLsIcOdZqtAG/1LlsM+JL0DA3bo0vCYaOYTBLXi/ZvRBqa20Ub8D2vKLNa+FgRQW0gRg04Ps1Pg=="], + "@expo/devtools": ["@expo/devtools@56.0.2", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ=="], - "@expo/dom-webview": ["@expo/dom-webview@55.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g=="], + "@expo/dom-webview": ["@expo/dom-webview@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew=="], - "@expo/env": ["@expo/env@2.1.2", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-RJtGFfj/ygO/6zcVbV3cckHf4THcEkv5IZft1GjCB3dfT6axvzvIwXE9EiQqQYmGHcQ+ZrvC8xZcIhiHba0pYg=="], + "@expo/env": ["@expo/env@2.3.0", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg=="], - "@expo/fingerprint": ["@expo/fingerprint@0.16.7", "", { "dependencies": { "@expo/env": "^2.1.2", "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-BH8sicYOqZ1iBMwCVEGIz6uTTfylosjc49FoMmCYIzKOiYdiVehsfoYBwyfxwWIiya1VMhm1gv0cgOP8fxHpDw=="], + "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.0.9", "", {}, "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA=="], - "@expo/image-utils": ["@expo/image-utils@0.8.14", "", { "dependencies": { "@expo/require-utils": "^55.0.5", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ=="], + "@expo/fingerprint": ["@expo/fingerprint@0.19.3", "", { "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw=="], + + "@expo/image-utils": ["@expo/image-utils@0.10.1", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg=="], + + "@expo/inline-modules": ["@expo/inline-modules@0.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8" } }, "sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ=="], "@expo/json-file": ["@expo/json-file@10.2.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ=="], - "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@55.0.13", "", { "dependencies": { "@expo/config": "~55.0.17", "chalk": "^4.1.2" } }, "sha512-Vg5BE10UL+0yg3BVtIeiSoeHU31Qe1m3UxhBPS478ACY1zzKuxZE30x2sym/B2OIWypjmPzXDRt8J9TOGFuFNw=="], + "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@56.0.8", "", { "dependencies": { "@expo/config": "~56.0.9", "chalk": "^4.1.2" } }, "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ=="], - "@expo/log-box": ["@expo/log-box@55.0.12", "", { "dependencies": { "@expo/dom-webview": "^55.0.6", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-f9ARS8J60cq3LLNdIqmUjYwyerBzVS5Ecp7KjIf3GOIPjW0571rkcwLz4/U18l/1DeSkSzIkYsNl2TC9oTdWaQ=="], + "@expo/log-box": ["@expo/log-box@56.0.12", "", { "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ=="], - "@expo/metro": ["@expo/metro@55.1.1", "", { "dependencies": { "metro": "0.83.7", "metro-babel-transformer": "0.83.7", "metro-cache": "0.83.7", "metro-cache-key": "0.83.7", "metro-config": "0.83.7", "metro-core": "0.83.7", "metro-file-map": "0.83.7", "metro-minify-terser": "0.83.7", "metro-resolver": "0.83.7", "metro-runtime": "0.83.7", "metro-source-map": "0.83.7", "metro-symbolicate": "0.83.7", "metro-transform-plugins": "0.83.7", "metro-transform-worker": "0.83.7" } }, "sha512-/wfXo5hTuAVpVLG/4hzlmD9NBGJkzkmBEMm/4VICajYRbj7y8OmqqPWbbymzHiBiHB6tI9BnsyXpQM6zVZEECg=="], + "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="], - "@expo/metro-config": ["@expo/metro-config@55.0.23", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~55.0.17", "@expo/env": "~2.1.2", "@expo/json-file": "~10.0.15", "@expo/metro": "~55.1.1", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.32.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.3", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-Mkw3Ss/1LFlafH3iie3r9E13yKMyJgZqGTEkGviGf6LYp51eY5fR8ATbXrNsH69wVc2z+ty4lT/8lEA18YJv7g=="], + "@expo/metro-config": ["@expo/metro-config@56.0.13", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~56.0.9", "@expo/env": "~2.3.0", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.33.3", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "msgpackr": "^2.0.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA=="], - "@expo/metro-runtime": ["@expo/metro-runtime@55.0.11", "", { "dependencies": { "@expo/log-box": "55.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-4KKi/jGrIEXi2YGu0hYTVr0CEeRJy5SXbCrz9+KDZkuD3ROwKNpM1DBawni5rhPVovFnR323HBck9GaxhnfrRw=="], + "@expo/metro-file-map": ["@expo/metro-file-map@56.0.3", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w=="], + + "@expo/metro-runtime": ["@expo/metro-runtime@56.0.13", "", { "dependencies": { "@expo/log-box": "^56.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg=="], "@expo/osascript": ["@expo/osascript@2.6.0", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg=="], "@expo/package-manager": ["@expo/package-manager@1.12.0", "", { "dependencies": { "@expo/json-file": "^10.2.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg=="], - "@expo/plist": ["@expo/plist@0.5.4", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg=="], + "@expo/plist": ["@expo/plist@0.7.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q=="], - "@expo/prebuild-config": ["@expo/prebuild-config@55.0.18", "", { "dependencies": { "@expo/config": "~55.0.17", "@expo/config-plugins": "~55.0.9", "@expo/config-types": "^55.0.5", "@expo/image-utils": "^0.8.14", "@expo/json-file": "^10.0.14", "@react-native/normalize-colors": "0.83.6", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2oKXyy5pyM87DJqXW5Z+Sakle6rApFFtpPhWOiNsOdoh6rOAD+EqVgyrs2OEEic8CE0tTt27w3SRfSZe/PZrxg=="], + "@expo/prebuild-config": ["@expo/prebuild-config@56.0.13", "", { "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/image-utils": "^0.10.1", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", "expo-modules-autolinking": "~56.0.13", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA=="], "@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="], - "@expo/require-utils": ["@expo/require-utils@55.0.5", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0" }, "optionalPeers": ["typescript"] }, "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw=="], + "@expo/require-utils": ["@expo/require-utils@56.1.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ=="], - "@expo/router-server": ["@expo/router-server@55.0.18", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^55.0.11", "expo": "*", "expo-constants": "^55.0.16", "expo-font": "^55.0.8", "expo-router": "*", "expo-server": "^55.0.11", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-W0VsvIiR48OvdlAOUlag4qspGYT/DV4srfYowlbYxwZh5Qw0MjiZAID4Zt7F0qynGZZxx8OZPpFhIX7XsqtRmg=="], + "@expo/router-server": ["@expo/router-server@56.0.12", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^56.0.13", "expo": "*", "expo-constants": "^56.0.16", "expo-font": "^56.0.5", "expo-router": "*", "expo-server": "^56.0.4", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ=="], - "@expo/schema-utils": ["@expo/schema-utils@55.0.4", "", {}, "sha512-65IdeeE8dAZR3n3J5Eq7LYiQ8BFGeEYCWPBCzycvafL7PkskbCyIclTQarRwf/HXFoRvezKCjaLwy/8v9Prk6g=="], + "@expo/schema-utils": ["@expo/schema-utils@56.0.1", "", {}, "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w=="], "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], @@ -395,7 +358,7 @@ "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], - "@expo/ui": ["@expo/ui@55.0.17", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NW/wFs+mhQeA/5W0yUNn6qKRNJBGiuF7NjCLZDFvg5Tyd+hDUEDEhBsLvI9MJ8B2aYuGcbuH9XZBIPF22LMrfQ=="], + "@expo/ui": ["@expo/ui@56.0.14", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-reanimated": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-reanimated", "react-native-worklets"] }, "sha512-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg=="], "@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="], @@ -415,24 +378,12 @@ "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], - "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], - - "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], - "@jellyfin/sdk": ["@jellyfin/sdk@0.13.0", "", { "peerDependencies": { "axios": "^1.12.0" } }, "sha512-oiBAOXH6s+dKdReSsYgNktBDzbxtg4JVWhEzIxZSxKcWMdSKmBtK41MhXRO7IWAC40DguKUm3nU/Z493qPAlWA=="], - "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], - - "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], - "@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="], - "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], - "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], - "@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="], - "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], "@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="], @@ -467,6 +418,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -543,44 +506,42 @@ "@react-native-community/netinfo": ["@react-native-community/netinfo@12.0.1", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ=="], + "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="], + "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="], - "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.83.6-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-iqgBSi1KSnWt8MPyVSKdu0+JiSPW1y398KLIgfi+7hTvHiVyjZqmCmybLbHyuJ3L5uBx625F4xK+6oRuWIbvCg=="], + "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.85.3-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-4Ifp8SCnvJnH+4SGwhpwFa1dzt3dh0uQ3+tdLKVDKL3yuOmbNCjUQ09q7i0+5r57tPoFKb4xmaW+7yKHaSTsfA=="], - "@react-native/assets-registry": ["@react-native/assets-registry@0.83.6", "", {}, "sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg=="], + "@react-native/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="], - "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.83.6", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.83.6" } }, "sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA=="], + "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.85.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" } }, "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA=="], - "@react-native/babel-preset": ["@react-native/babel-preset@0.83.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.83.6", "babel-plugin-syntax-hermes-parser": "0.32.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw=="], + "@react-native/babel-preset": ["@react-native/babel-preset@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-syntax-hermes-parser": "0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw=="], - "@react-native/codegen": ["@react-native/codegen@0.83.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.32.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A=="], + "@react-native/codegen": ["@react-native/codegen@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA=="], - "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.83.6", "", { "dependencies": { "@react-native/dev-middleware": "0.83.6", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.83.6", "metro-config": "^0.83.6", "metro-core": "^0.83.6", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "*" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-Mko6mywoHYJmpBnjwAC95vQWaUUh//71knFadH0BrhHDq2m7i/IrpLwcQsPAy8855ucXflBs5zQyGTpNbPBAaw=="], + "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.3", "", { "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw=="], - "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.83.6", "", {}, "sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA=="], + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.3", "", {}, "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A=="], - "@react-native/debugger-shell": ["@react-native/debugger-shell@0.83.6", "", { "dependencies": { "cross-spawn": "^7.0.6", "fb-dotslash": "0.5.8" } }, "sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg=="], + "@react-native/debugger-shell": ["@react-native/debugger-shell@0.85.3", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ=="], - "@react-native/dev-middleware": ["@react-native/dev-middleware@0.83.6", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.83.6", "@react-native/debugger-shell": "0.83.6", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA=="], + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.85.3", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.3", "@react-native/debugger-shell": "0.85.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA=="], - "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.83.6", "", {}, "sha512-5prXv7WWR1RgZ/kWGZP+mi7/y/IE2ymfOHIZO5Pv14tMOmRAcQSgSYogcRmOiWw5mJs2K0UFeMiQD49ZO9oCug=="], + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.3", "", {}, "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA=="], - "@react-native/js-polyfills": ["@react-native/js-polyfills@0.83.6", "", {}, "sha512-VSev0LV2i5X0ibduHBSLqKj0YU2F+waCgjl2uvaGHMGCSV1ZRKNFX/vJFqvLwjvdzLbkAZoFT1Rg7k7jDv44UA=="], + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="], - "@react-native/normalize-colors": ["@react-native/normalize-colors@0.83.6", "", {}, "sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw=="], + "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.85.3", "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" } }, "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.16.2", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Lbp++BGMc7SQXnyKuO/JrQJIhFH0zyB5v4kIEbnzDJLJfgubd5hoSe+QfCqy4YHfLA4phC4Xf/6Q2Ic8x7datQ=="], + "@react-native/metro-config": ["@react-native/metro-config@0.85.3", "", { "dependencies": { "@react-native/js-polyfills": "0.85.3", "@react-native/metro-babel-transformer": "0.85.3", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA=="], + + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="], "@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="], - "@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="], - - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="], - "@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="], - "@react-navigation/native-stack": ["@react-navigation/native-stack@7.14.14", "", { "dependencies": { "@react-navigation/elements": "^2.9.17", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.2.4", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-KCKwnooV05vPw7PGqMoNmCJXARjsp51DRw/3Bw9tjOLGBkmLaUbOJJuM7IQXcI+1EWE4GjBYrfIPtiARGNUg1g=="], - "@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="], "@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="], @@ -593,10 +554,6 @@ "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], - "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], - - "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], @@ -617,20 +574,18 @@ "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], - "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], - "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], "@types/hoist-non-react-statics": ["@types/hoist-non-react-statics@3.3.7", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "@types/react": "*" } }, "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g=="], @@ -645,7 +600,7 @@ "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], @@ -719,6 +674,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], "astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="], @@ -729,12 +686,6 @@ "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], - "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], - - "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], - - "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], - "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], @@ -745,15 +696,11 @@ "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], - "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.32.0", "", { "dependencies": { "hermes-parser": "0.32.0" } }, "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg=="], + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.33.3", "", { "dependencies": { "hermes-parser": "0.33.3" } }, "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA=="], "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], - "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], - - "babel-preset-expo": ["babel-preset-expo@55.0.22", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.83.6", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^55.0.19", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-Se6kPnvCNN13jJVIa6JJvlmImVoVRzu9stagAbivCPcfrq2VNrsEiYpJZ1+H32kXinKW/y797/wctGuxPy0APw=="], - - "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], + "babel-preset-expo": ["babel-preset-expo@56.0.13", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.15", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ=="], "badgin": ["badgin@1.2.3", "", {}, "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="], @@ -765,8 +712,6 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], - "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], - "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -819,7 +764,7 @@ "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], - "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], + "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -855,8 +800,6 @@ "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -887,6 +830,8 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -905,12 +850,12 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -927,6 +872,8 @@ "dnssd-advertise": ["dnssd-advertise@1.1.4", "", {}, "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -973,8 +920,6 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -989,93 +934,95 @@ "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], - "expo": ["expo@55.0.26", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "55.0.32", "@expo/config": "~55.0.17", "@expo/config-plugins": "~55.0.10", "@expo/devtools": "55.0.3", "@expo/fingerprint": "0.16.7", "@expo/local-build-cache-provider": "55.0.13", "@expo/log-box": "55.0.12", "@expo/metro": "~55.1.1", "@expo/metro-config": "55.0.23", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~55.0.22", "expo-asset": "~55.0.17", "expo-constants": "~55.0.16", "expo-file-system": "~55.0.22", "expo-font": "~55.0.8", "expo-keep-awake": "~55.0.8", "expo-modules-autolinking": "55.0.24", "expo-modules-core": "55.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-MuVW6Uzd/Jh6E37ICOYAiTOm9nflNMUNzf6wH5ld/IXFyuF2Lo86a8fCSMgHcvTGsSjRsJ5Uxhf+WHZcvGPfrg=="], + "expo": ["expo@56.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.12", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.3", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.13", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.14", "expo-modules-core": "~56.0.13", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-zcFa/+6hGtzCUlcrGiusvzr/PIoNBAnjj4PlAFrvbAOZcVOj6c9Mp7lRSn9XYJk8Ok6pssQWt6dP4llJlKmYRQ=="], - "expo-application": ["expo-application@55.0.15", "", { "peerDependencies": { "expo": "*" } }, "sha512-eWf5OGrat1r/TYr0daL684C6pJYiN2k30NmcISsD9fbtZLfA6Sbo/JebfYzP3OjO/T/i8j/9Pf3ePqUYPLfZnw=="], + "expo-application": ["expo-application@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw=="], - "expo-asset": ["expo-asset@55.0.17", "", { "dependencies": { "@expo/image-utils": "^0.8.14", "expo-constants": "~55.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-pK9HHJuFqjE8kDUcbMFsZj3Cz8WdXpvZHZmYl7ouFQp59P83BvHln6VnqPDGlO+/4929G0Lm8ZUzbONuNRhi9w=="], + "expo-asset": ["expo-asset@56.0.15", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ=="], - "expo-audio": ["expo-audio@55.0.14", "", { "peerDependencies": { "expo": "*", "expo-asset": "*", "react": "*", "react-native": "*" } }, "sha512-Biy6ffKXrnKHgcWSVWLKVdWLNhV/pj1JWJeotY6nDR6fVe8mjXQDCvi6EbaSFPdffVHym6UB2siKzWUNSnG+kQ=="], + "expo-audio": ["expo-audio@56.0.11", "", { "peerDependencies": { "expo": "*", "expo-asset": "*", "react": "*", "react-native": "*" } }, "sha512-naionxilr49IpEjmMqCj5gXHCSfOsgu3nZ/KXndexR05Tv6dET7dmespyZkcMrADJN07gA5hyqPUC5WqWuaFLw=="], - "expo-background-task": ["expo-background-task@55.0.18", "", { "dependencies": { "expo-task-manager": "~55.0.16" }, "peerDependencies": { "expo": "*" } }, "sha512-qcSHSRgqfKTkGy//t4MTwqEWacvTKM/lJQEaW/3/R0XAcvc9RA8FnLJwc5cqHrKSt78vgp9klZtmcsv9NH9Knw=="], + "expo-background-task": ["expo-background-task@56.0.15", "", { "dependencies": { "expo-task-manager": "~56.0.15" }, "peerDependencies": { "expo": "*" } }, "sha512-ZBzLkKFmM5ZpJYl1D1kpmk6MomLbVx6LQbMX4GGLg8TSidvvtden0haIw4R5Rpkgzj3LOjvFMFli5a4kQA7VCA=="], - "expo-blur": ["expo-blur@55.0.14", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NKyCKFWTNpX4CZSsiE1sgkqk/yvR1K0UTukuIbxVKoobB+yALLg1CFav0NqfdQqjhtoj5oEzP0Brlq92Z08Zfg=="], + "expo-blur": ["expo-blur@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ=="], - "expo-brightness": ["expo-brightness@55.0.13", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-vFMrB+ZLn9CBO4cTngh7oaogzYEhUlXvA0/V+iWBYwXrSQNb1lYZIfV2KYsehnwWvox8mDlVEGXtd9rU5Hi11A=="], + "expo-brightness": ["expo-brightness@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-AkCGW+Lj8I4o2+Yjs1bzjIJz44cgNXfAN+pf01uDwmA1/1JTIy8x1eISvmz6d2r/1OhdyBZxeDkACNLVMDx5zA=="], - "expo-build-properties": ["expo-build-properties@55.0.14", "", { "dependencies": { "@expo/schema-utils": "^55.0.4", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5wopuLXlzFpDnCfsIjgAcj4yKnVTYg3XYGnYV5Hqi7u6jJipLG4fwtUqxVIFqBj7vEHXjd4zYCyXjp39AtAD7w=="], + "expo-build-properties": ["expo-build-properties@56.0.15", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g=="], - "expo-camera": ["expo-camera@55.0.19", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-EUGEo7m/cY6u8XyLKzavYWs1XP53vvg2LCCBM4nkY8hhUzQ3DeClYr9G3ew0JV2d8WOI15Yyj1Xoe8CjD3ySbg=="], + "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], - "expo-constants": ["expo-constants@55.0.16", "", { "dependencies": { "@expo/env": "~2.1.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ=="], + "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], - "expo-crypto": ["expo-crypto@55.0.15", "", { "peerDependencies": { "expo": "*" } }, "sha512-nlxLguQyJM4MhDDERL30WZkq68/BujOcAp4QdGk+1pZmUmG1p2M8cF7GeFwYvWwjH0DVsIRtRh4ukeTvOZVTPg=="], + "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], - "expo-dev-client": ["expo-dev-client@55.0.35", "", { "dependencies": { "expo-dev-launcher": "55.0.36", "expo-dev-menu": "55.0.30", "expo-dev-menu-interface": "55.0.2", "expo-manifests": "~55.0.17", "expo-updates-interface": "~55.1.6" }, "peerDependencies": { "expo": "*" } }, "sha512-DN50x9gqWYAfnJpxgiJm3zK2bFvDhxJ5JjFq0wFot7o4knZ7H3BVwiL6zZMHG29g6gfxdgpzGG69WPiSR/Ipgg=="], + "expo-dev-client": ["expo-dev-client@56.0.16", "", { "dependencies": { "expo-dev-launcher": "~56.0.16", "expo-dev-menu": "~56.0.15", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg=="], - "expo-dev-launcher": ["expo-dev-launcher@55.0.36", "", { "dependencies": { "@expo/schema-utils": "^55.0.4", "expo-dev-menu": "55.0.30", "expo-manifests": "~55.0.17" }, "peerDependencies": { "expo": "*" } }, "sha512-Dn2om4J71aavWqi1jLzK3QlGZjDiFv7nIBZkQyzy2zW62IOD9kLwOOvHHj07Ra/6n9cqFEpNYzwpPkR7KHuYZA=="], + "expo-dev-launcher": ["expo-dev-launcher@56.0.16", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "expo-dev-menu": "~56.0.15", "expo-manifests": "~56.0.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw=="], - "expo-dev-menu": ["expo-dev-menu@55.0.30", "", { "dependencies": { "expo-dev-menu-interface": "55.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-uwDI4cEPzpRemf06Ts5O41azJcz8BBcE6QOkNaTX8JlzdJ05eq9jWxmbA1WhoSoE5C+NFo8njHSvmHqUqTpOng=="], + "expo-dev-menu": ["expo-dev-menu@56.0.15", "", { "dependencies": { "expo-dev-menu-interface": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ=="], - "expo-dev-menu-interface": ["expo-dev-menu-interface@55.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-DomUNvGzY/xliwnMdbAYY780sCv19N7zIbifc0ClcoCzJZpNSCkvJ2qGIFRPyM/7DmqmlHGCKi8di7kYYLKNEg=="], + "expo-dev-menu-interface": ["expo-dev-menu-interface@56.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA=="], - "expo-device": ["expo-device@55.0.17", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ZcMrSeD0zWooosm5Bet5qluxUrhw+NPgcMmN6ySVF7cm4K8Bvh4KPogJePbI1qfhFAiJWcWeV9e/2uewb9ktfw=="], + "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], "expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="], - "expo-file-system": ["expo-file-system@55.0.22", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-T5Rfv3vqcFyhVrl/tEEeglc/J8LJbcZQgC3TMT5jxzIgUgWmIgJEgncGYqB/YNXFgUTL2LiuCvqrU51Dzp83NQ=="], + "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], - "expo-font": ["expo-font@55.0.8", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-WyP75pnKqhLNktYwDn3xKAUNt5rLihRDv8XWGhhz6VEhVqypixpT86NA3uGtiDTlM3gGjhrYCY7o7ypXgCUOZg=="], + "expo-font": ["expo-font@56.0.5", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw=="], - "expo-glass-effect": ["expo-glass-effect@55.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-wqq7GUOqSkfoFJzreZvBG0jzjsq5c582m3glhWSjcmIuByxXXWp6j6GY6hyFuYKzpOXhbuvusVxGCQi0yWnp3g=="], + "expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="], - "expo-haptics": ["expo-haptics@55.0.14", "", { "peerDependencies": { "expo": "*" } }, "sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g=="], + "expo-haptics": ["expo-haptics@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw=="], - "expo-image": ["expo-image@55.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-PVIBYQJW/h1f6Zb9xnoWlgfqyOPVm2yb6eo6ZogaKbvMrhb/Q/fiERbagi4oqmR6IPljWPEpkXXQyFBUh7TjpQ=="], + "expo-image": ["expo-image@56.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FifiRehXnMul5XeUVHWv+COHFUeCAdsYf5MiCPUBlhr4pRb0sxjA4/floi/TEDpATOIw6GqxbrC4FdZBoyrJmw=="], - "expo-json-utils": ["expo-json-utils@55.0.2", "", {}, "sha512-QJMOZOPOG7CTnKcrdVaiummn2va1MCO56z++eyWkDv3GBRODldM6MFMDf/jTREWthFc2Nxo6TuyWRrEV9S6n/Q=="], + "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="], - "expo-keep-awake": ["expo-keep-awake@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-PfIpMfM+STOBwkR5XOE+yVtER86c44MD+W8QD8JxuO0sT9pF7Y1SJYakWlpvX8xsGA+bjKLxftm9403s9kQhKA=="], + "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], - "expo-linear-gradient": ["expo-linear-gradient@55.0.14", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-n01A9P0ZebRo8Rm4QHYEjMR8z4Y6MBc8uVnT8NB9cOJislvEmz2o2WQJoK6RD7FjoeFEW8KkRtggK7fQ3h7KIg=="], + "expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="], - "expo-linking": ["expo-linking@55.0.15", "", { "dependencies": { "expo-constants": "~55.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/RQh2vkNqV8Bim9Owm/evVqn2fqTvCDYHkpYPoSKbLAdydSGdHC2xZNw7Odl4wu1i1/3L4Xz//LKd3NsPWYWBQ=="], + "expo-linking": ["expo-linking@56.0.12", "", { "dependencies": { "expo-constants": "~56.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ=="], - "expo-localization": ["expo-localization@55.0.15", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-+HD55LeeIWyVRLvpQ909Am89XS16dUBkbB4/ruCJXS9oWv1K8W+FoXuOPTpmdvwHfC9cxt0loiwPWUiw2fdgbg=="], + "expo-localization": ["expo-localization@56.0.6", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA=="], - "expo-location": ["expo-location@55.1.10", "", { "dependencies": { "@expo/image-utils": "^0.8.14" }, "peerDependencies": { "expo": "*" } }, "sha512-MkcFucsZ567Bn8ChElVTYVbOs2QXn27IKaBrVKogw7ZcbooImdj3L/UR6E7s3LkgF33YubKynAp9Opvixdwl7g=="], + "expo-location": ["expo-location@56.0.14", "", { "dependencies": { "@expo/image-utils": "^0.10.1" }, "peerDependencies": { "expo": "*" } }, "sha512-k9p6mR11o5S0R4yUs3uWLJfnSk6XIB9UIgSYiNu2goGLWb2f0sazuZ0iYhuc2p2wIsdidhpL/51ZXjtZl5JCOg=="], - "expo-manifests": ["expo-manifests@55.0.17", "", { "dependencies": { "expo-json-utils": "~55.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-vKZvFivX3usVJKfBODKQcFHso0g38zlGbRGqGAppz+il0zKvG6umpJ47OZbzLod7iJpjd+ZDD2AGuOxacixonA=="], + "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="], - "expo-modules-autolinking": ["expo-modules-autolinking@55.0.24", "", { "dependencies": { "@expo/require-utils": "^55.0.5", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-A0OyMbTPZqibYrwqj98HFYTNSvl4NSS4Zt+R5A8qiAx3nM0mc81e6Iqw7Wl4J8M/t36lJ+cT3WuVTz5Oszj6Hw=="], + "expo-modules-autolinking": ["expo-modules-autolinking@56.0.14", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg=="], - "expo-modules-core": ["expo-modules-core@55.0.25", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-yXpfg7aHLbuqoXocK34Vua6Aey5SCyqLygAsXAMbul9P8vfBjLpaOPiTJ5cLVF7Drfq8ownqVJO6qpGEtZ6GOw=="], + "expo-modules-core": ["expo-modules-core@56.0.13", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ=="], - "expo-notifications": ["expo-notifications@55.0.23", "", { "dependencies": { "@expo/image-utils": "^0.8.14", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~55.0.15", "expo-constants": "~55.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-oWEsBZSedRFu+ErcJaIev7QQhCE/gTRO6KURAkFpMflMgI3yjT4O/qixXh4tHmEk5zfoOpR2u79AzvVcchfwww=="], + "expo-modules-jsi": ["expo-modules-jsi@56.0.7", "", { "peerDependencies": { "react-native": "*" } }, "sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA=="], - "expo-router": ["expo-router@55.0.16", "", { "dependencies": { "@expo/metro-runtime": "^55.0.11", "@expo/schema-utils": "^55.0.4", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/native": "^7.1.33", "@react-navigation/native-stack": "^7.14.5", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^55.0.11", "expo-image": "^55.0.11", "expo-server": "^55.0.11", "expo-symbols": "^55.0.9", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.2.1", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "55.0.12", "@react-navigation/drawer": "^7.9.4", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^55.0.16", "expo-linking": "^55.0.15", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-xVwWsDz3Ar2+3hRpMMrZMYFzkJak322vCA5/XCP7WOL0hEXnWhgQGhv5IEYZyz/TXZbl2IYD6/1MnH9mBhjwKQ=="], + "expo-notifications": ["expo-notifications@56.0.14", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~56.0.3", "expo-constants": "~56.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-A+BDJYyBIkC17Bfqlrbf9A80npjOyoTbaSCydP2agfhVv+Ld7DuOYOJSApBmtzBZM0LvdUVX/pdrwjEp1ixmaw=="], - "expo-screen-orientation": ["expo-screen-orientation@55.0.16", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-I9NIqb2zAkHsK/CxdmMdmgSFP7E1v++8z/Mj2X9j1AuK6l55yOma/JHo905KU3x2zPm9/l1BTzmMA320tiBebg=="], + "expo-router": ["expo-router@56.2.7", "", { "dependencies": { "@expo/log-box": "^56.0.12", "@expo/metro-runtime": "^56.0.13", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.14", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.4", "expo-symbols": "^56.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.16", "expo-linking": "^56.0.12", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA=="], - "expo-secure-store": ["expo-secure-store@55.0.14", "", { "peerDependencies": { "expo": "*" } }, "sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ=="], + "expo-screen-orientation": ["expo-screen-orientation@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Puf4L/cgM8z45Z2fwZzJtlVGSk0ZM/l3gBqXm50bKTACmUk8P8fr7HVbDfs8reyoZuEKKFZJ0VlnKo5i6cSotQ=="], - "expo-server": ["expo-server@55.0.11", "", {}, "sha512-AxRdHqcv0H1g4s923vu+5n1Nrhne23bjXbP+Vl7+Lwfpe7MG9PuU1IS95IJK6a+7BVV1mRN6QlZvs8Yv7EEXNQ=="], + "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="], - "expo-sharing": ["expo-sharing@55.0.20", "", { "dependencies": { "@expo/config-plugins": "^55.0.10", "@expo/config-types": "^55.0.5", "@expo/plist": "^0.5.4" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KhDCsqPmDFFEWM+NlJe+nn0Z48hYeprNpwtNa5wQ4JgXxr2FL7i+AXjLh6v6kdHO2+r6+gkDft/u4fTDgYXdtA=="], + "expo-server": ["expo-server@56.0.4", "", {}, "sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA=="], - "expo-splash-screen": ["expo-splash-screen@55.0.21", "", { "dependencies": { "@expo/prebuild-config": "^55.0.18" }, "peerDependencies": { "expo": "*" } }, "sha512-hFGEap69ggCckbHIdDXMe5rqfBR9TwcnY5gBhyaACUxU64w827T6prOQcIvLmAdv00kp3Gqt7hgE+mNn37EF+A=="], + "expo-sharing": ["expo-sharing@56.0.14", "", { "dependencies": { "@expo/config-plugins": "^56.0.8", "@expo/config-types": "^56.0.5", "@expo/plist": "^0.7.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-Hu7pm3U9vn9NFGBe5EUM6ct6wBhAc7Zgl5koOYpJnMvL6n85bkIA8sLvvxB6V+p4JRoh3TD6xXpOIr23qwsV2w=="], - "expo-status-bar": ["expo-status-bar@55.0.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ijOUptfdiqYt7rObZ6jrPQ8sE5YN/8MxKCIJx0b7TY4nGkSJxhPIxeoW4GXcXCA8mTQ9PiOHH/ThLZgRVZvUlQ=="], + "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="], - "expo-symbols": ["expo-symbols@55.0.9", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-F85C/8ExQjd2gYjasLVKMT8wPj+1+19TVTqg4jAeVjVZklqiQtLO72io9Ji1xAjYNgmDeUI0diVHlFMMTC4Ekg=="], + "expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="], - "expo-system-ui": ["expo-system-ui@55.0.18", "", { "dependencies": { "@react-native/normalize-colors": "0.83.6", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-Fbc0HJgqMpABeA/gI7NJFnSXwUeLrEMjjXq8Nl+4gTXyacIK2iOOrzCkvq41rKBBde0CR6kVnB1DXj0j9ZYnjg=="], + "expo-symbols": ["expo-symbols@56.0.5", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw=="], - "expo-task-manager": ["expo-task-manager@55.0.16", "", { "dependencies": { "unimodules-app-loader": "~55.0.5" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-MR3eg/nyOlTErh38hL3cDXvBhhRtiXDoh1iYjHVeFs8xZ89zB3Y5CPDkK8HgxGrfOZdeI7ntrHJD9Niy6WnlVQ=="], + "expo-system-ui": ["expo-system-ui@56.0.5", "", { "dependencies": { "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg=="], - "expo-updates-interface": ["expo-updates-interface@55.1.6", "", { "peerDependencies": { "expo": "*" } }, "sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw=="], + "expo-task-manager": ["expo-task-manager@56.0.15", "", { "dependencies": { "unimodules-app-loader": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8vbKYocXJHv27++9AubVaEvVujTdt5Z10XddaxHAhWO60uw1Zom6yRjSAayRbZ5hNFA1c72KfA2vOETXZR9IGg=="], - "expo-web-browser": ["expo-web-browser@55.0.16", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-eeGs3439ewO/Q56Pzg3qbAVZSE0oH/R7XW9VCXI59k0m78ZIYbBtPT4PMFL/+sBgRkXm546Lq/DFcJQPTOfXJg=="], + "expo-updates-interface": ["expo-updates-interface@56.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g=="], + + "expo-web-browser": ["expo-web-browser@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -1085,8 +1032,6 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], @@ -1131,8 +1076,6 @@ "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1147,8 +1090,6 @@ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -1175,11 +1116,11 @@ "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - "hermes-compiler": ["hermes-compiler@0.14.1", "", {}, "sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA=="], + "hermes-compiler": ["hermes-compiler@250829098.0.10", "", {}, "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w=="], - "hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="], + "hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="], - "hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="], + "hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], @@ -1211,9 +1152,7 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1251,28 +1190,16 @@ "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], - "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], - "jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="], - "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], - "jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="], - "jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], "jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], - "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], - - "jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="], - "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], @@ -1369,6 +1296,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -1385,33 +1314,33 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "metro": ["metro@0.83.7", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.7", "metro-cache": "0.83.7", "metro-cache-key": "0.83.7", "metro-config": "0.83.7", "metro-core": "0.83.7", "metro-file-map": "0.83.7", "metro-resolver": "0.83.7", "metro-runtime": "0.83.7", "metro-source-map": "0.83.7", "metro-symbolicate": "0.83.7", "metro-transform-plugins": "0.83.7", "metro-transform-worker": "0.83.7", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ=="], + "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="], - "metro-babel-transformer": ["metro-babel-transformer@0.83.7", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.83.7", "nullthrows": "^1.1.1" } }, "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA=="], + "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="], - "metro-cache": ["metro-cache@0.83.7", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.7" } }, "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg=="], + "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="], - "metro-cache-key": ["metro-cache-key@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg=="], + "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="], - "metro-config": ["metro-config@0.83.7", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.7", "metro-cache": "0.83.7", "metro-core": "0.83.7", "metro-runtime": "0.83.7", "yaml": "^2.6.1" } }, "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q=="], + "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="], - "metro-core": ["metro-core@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.7" } }, "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg=="], + "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="], - "metro-file-map": ["metro-file-map@0.83.7", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw=="], + "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="], - "metro-minify-terser": ["metro-minify-terser@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ=="], + "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="], - "metro-resolver": ["metro-resolver@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A=="], + "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="], - "metro-runtime": ["metro-runtime@0.83.7", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ=="], + "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="], - "metro-source-map": ["metro-source-map@0.83.7", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.7", "nullthrows": "^1.1.1", "ob1": "0.83.7", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw=="], + "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="], - "metro-symbolicate": ["metro-symbolicate@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.7", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw=="], + "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="], - "metro-transform-plugins": ["metro-transform-plugins@0.83.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA=="], + "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="], - "metro-transform-worker": ["metro-transform-worker@0.83.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.83.7", "metro-babel-transformer": "0.83.7", "metro-cache": "0.83.7", "metro-cache-key": "0.83.7", "metro-minify-terser": "0.83.7", "metro-source-map": "0.83.7", "metro-transform-plugins": "0.83.7", "nullthrows": "^1.1.1" } }, "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw=="], + "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1425,6 +1354,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1435,6 +1366,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -1451,6 +1386,8 @@ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], @@ -1469,7 +1406,7 @@ "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], - "ob1": ["ob1@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg=="], + "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1485,8 +1422,6 @@ "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], @@ -1517,8 +1452,6 @@ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1593,11 +1526,11 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], @@ -1607,7 +1540,7 @@ "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], - "react-native": ["react-native-tvos@0.83.6-0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.83.6-0", "@react-native/assets-registry": "0.83.6", "@react-native/codegen": "0.83.6", "@react-native/community-cli-plugin": "0.83.6", "@react-native/gradle-plugin": "0.83.6", "@react-native/js-polyfills": "0.83.6", "@react-native/normalize-colors": "0.83.6", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.6", "metro-source-map": "^0.83.6", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-CfEKKCKltPhXswJaiM9xeqFV/AookPL7EnM0CQmW0BwnxPuhuK5ckTm2L8zoDzSu6lQYjvWfXoCWS1cIhQ3t0w=="], + "react-native": ["react-native-tvos@0.85.3-0", "", { "dependencies": { "@react-native-tvos/virtualized-lists": "0.85.3-0", "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", "@react-native/community-cli-plugin": "0.85.3", "@react-native/gradle-plugin": "0.85.3", "@react-native/js-polyfills": "0.85.3", "@react-native/normalize-colors": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-Q9gUndppXbGEiYlQ8eudkdH7rDXdY+KM74Btd5xqMvXHgo7ZXdwI1hKvStmI47KmTaDn0NOmcRl2yBwHfc5+5A=="], "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], @@ -1623,9 +1556,11 @@ "react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="], + "react-native-drawer-layout": ["react-native-drawer-layout@4.2.4", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg=="], + "react-native-edge-to-edge": ["react-native-edge-to-edge@1.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bhvsKqeX9PGkY9wBUk9vni/tJNJdKtLPbs/j3e/3CdV4JmUWfTXYYoL+4Hc8Wmej+5eJxkc8KOFa454ruFWBCA=="], - "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], "react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="], @@ -1643,21 +1578,19 @@ "react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="], - "react-native-pager-view": ["react-native-pager-view@8.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg=="], + "react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="], "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="], - "react-native-reanimated": ["react-native-reanimated@4.2.1", "", { "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": ">=0.7.0" } }, "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg=="], + "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="], - "react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="], + "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], - "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="], + "react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="], - "react-native-svg": ["react-native-svg@15.15.3", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA=="], - - "react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="], + "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], @@ -1673,7 +1606,7 @@ "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], - "react-native-worklets": ["react-native-worklets@0.7.4", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag=="], + "react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -1693,6 +1626,8 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -1721,8 +1656,6 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1795,8 +1728,6 @@ "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], @@ -1821,6 +1752,8 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], @@ -1845,8 +1778,6 @@ "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], - "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], - "text-encoding": ["text-encoding@0.7.0", "", {}, "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -1883,8 +1814,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], - "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], @@ -1893,7 +1822,7 @@ "ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], @@ -1903,7 +1832,7 @@ "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], - "unimodules-app-loader": ["unimodules-app-loader@55.0.5", "", {}, "sha512-2eLjtaAVQTK3EeiUAgRbfEnX78f6cMtw5Js8Ri4OcEdkrozsmvG3Wu8YVfr6kfhea17FHZkKZmO1m4dL/Ky2Bg=="], + "unimodules-app-loader": ["unimodules-app-loader@56.0.1", "", {}, "sha512-Z801jeBOQMUF/ExklxT1BqhEV/oF2/Bii7PFYAj/8Sauxl7oKvZbf70peRzzAU0mG7UQ3yU/UO/EpD1JyJ2WcA=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -1961,10 +1890,6 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], - "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], @@ -2019,8 +1944,6 @@ "@expo/config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@expo/config-plugins/@expo/json-file": ["@expo/json-file@10.0.15", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-xLtsy1820Rf2myhhIc7WmfoUg5cWEJB9tEylhgGhRF/acYGuUXUVkKHYoHY31GbYf6CIZNvipTFxuvWRpVlXTw=="], - "@expo/config-plugins/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/config-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -2041,7 +1964,7 @@ "@expo/image-utils/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@expo/metro-config/@expo/json-file": ["@expo/json-file@10.0.15", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-xLtsy1820Rf2myhhIc7WmfoUg5cWEJB9tEylhgGhRF/acYGuUXUVkKHYoHY31GbYf6CIZNvipTFxuvWRpVlXTw=="], + "@expo/metro-config/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], @@ -2051,13 +1974,9 @@ "@expo/prebuild-config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "@expo/require-utils/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - - "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - - "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], @@ -2077,22 +1996,22 @@ "@react-native-community/cli-tools/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@react-native/codegen/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + "@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], @@ -2101,20 +2020,18 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "chrome-launcher/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "chromium-edge-launcher/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2131,7 +2048,7 @@ "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "expo-router/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2159,8 +2076,12 @@ "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-util/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "jest-worker/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2175,6 +2096,8 @@ "logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "metro/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], @@ -2183,18 +2106,22 @@ "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], "metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "metro-transform-plugins/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + + "metro-transform-worker/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="], "nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], @@ -2221,28 +2148,22 @@ "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "react-native/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], + "react-native-drawer-layout/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "react-native-reanimated/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], - "react-native-worklets/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], - - "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "react-native-worklets/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -2265,10 +2186,6 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "test-exclude/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -2303,31 +2220,11 @@ "@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], - "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - - "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@jest/types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], - "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - - "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@react-navigation/native-stack/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/native-stack/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -2335,10 +2232,12 @@ "ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2347,8 +2246,16 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "expo-router/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "expo-router/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "jest-util/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "jest-worker/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -2381,8 +2288,6 @@ "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -2395,18 +2300,16 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "react-native/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "react-native-drawer-layout/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "react-native-drawer-layout/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -2433,26 +2336,6 @@ "@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - - "@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - - "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/native-stack/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/native-stack/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -2461,6 +2344,10 @@ "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "expo-router/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "expo-router/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2479,9 +2366,9 @@ "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "react-native-drawer-layout/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "react-native-drawer-layout/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -2497,8 +2384,6 @@ "@expo/package-manager/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], - "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index d17e6b8da..3c5fa57f1 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -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; - navigation: any; - }) => NativeStackNavigationOptions); +type ICommonScreenOptions = ComponentProps["options"]; export const commonScreenOptions: ICommonScreenOptions = { title: "", diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx index f26885298..f1f3ccf92 100644 --- a/components/tv/TVSubtitleResultCard.tsx +++ b/components/tv/TVSubtitleResultCard.tsx @@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType) => color: "#fff", }, downloadingOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, backgroundColor: "rgba(0,0,0,0.5)", borderRadius: scaleSize(14), justifyContent: "center", diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 2e7fab497..14ebd8ac2 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -1408,14 +1408,14 @@ export const Controls: FC = ({ 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: { diff --git a/package.json b/package.json index bbd5393d6..481e2f178 100644 --- a/package.json +++ b/package.json @@ -28,61 +28,58 @@ "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/react-pacer": "^0.19.1", "@tanstack/react-query": "5.90.20", "@tanstack/react-query-persist-client": "^5.90.18", "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", + "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": "^25.0.0", "jotai": "2.16.2", "lodash": "4.17.23", "nativewind": "^2.0.11", "patch-package": "^8.0.0", - "react": "19.2.0", - "react-dom": "19.2.0", + "react": "19.2.3", + "react-dom": "19.2.3", "react-i18next": "16.5.3", - "react-native": "npm:react-native-tvos@0.83.6-0", + "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 +88,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 +96,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,14 +110,14 @@ "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" }, "devDependencies": { - "@babel/core": "7.28.6", + "@babel/core": "7.29.7", "@biomejs/biome": "2.3.11", "@react-native-community/cli": "20.1.3", "@react-native-tvos/config-tv": "0.1.6", @@ -148,6 +145,7 @@ }, "install": { "exclude": [ + "react-native", "react-native-screens" ] } @@ -165,9 +163,7 @@ "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" } } diff --git a/utils/useReactNavigationQuery.ts b/utils/useReactNavigationQuery.ts index 1cbe40e86..648debca9 100644 --- a/utils/useReactNavigationQuery.ts +++ b/utils/useReactNavigationQuery.ts @@ -1,10 +1,10 @@ -import { useFocusEffect } from "@react-navigation/core"; import { type QueryKey, type UseQueryOptions, type UseQueryResult, useQuery, } from "@tanstack/react-query"; +import { useFocusEffect } from "expo-router/react-navigation"; import { useCallback } from "react"; export function useReactNavigationQuery< From 74f5844ed7c99809e641449c44ec85e5f2f5214c Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 00:16:15 +0200 Subject: [PATCH 263/309] 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. --- app.json | 2 +- bun.lock | 1 + package.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app.json b/app.json index 8751d44c6..3326f4e4a 100644 --- a/app.json +++ b/app.json @@ -86,7 +86,7 @@ "compileSdkVersion": 36, "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", - "kotlinVersion": "2.0.21", + "kotlinVersion": "2.1.20", "minSdkVersion": 26, "usesCleartextTraffic": true, "packagingOptions": { diff --git a/bun.lock b/bun.lock index f9195aeda..ed088a24c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^12.0.0", + "@react-navigation/native": "^7.2.5", "@shopify/flash-list": "2.0.2", "@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/react-pacer": "^0.19.1", diff --git a/package.json b/package.json index 481e2f178..f785937ea 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^12.0.0", + "@react-navigation/native": "^7.2.5", "@shopify/flash-list": "2.0.2", "@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/react-pacer": "^0.19.1", From 708d0e8d2e316de30648d9ee70f73aa70c39739f Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 00:44:02 +0200 Subject: [PATCH 264/309] 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] --- bun.lock | 34 ++++++++++++++++------------------ package.json | 18 +++++++++--------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index ed088a24c..b9546e2ac 100644 --- a/bun.lock +++ b/bun.lock @@ -16,10 +16,10 @@ "@react-native-community/netinfo": "^12.0.0", "@react-navigation/native": "^7.2.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": "~56.0.6", "expo-application": "~56.0.3", @@ -51,14 +51,14 @@ "expo-system-ui": "~56.0.5", "expo-task-manager": "~56.0.15", "expo-web-browser": "~56.0.5", - "i18next": "^25.0.0", - "jotai": "2.16.2", - "lodash": "4.17.23", + "i18next": "^26.3.0", + "jotai": "2.20.0", + "lodash": "4.18.1", "nativewind": "^2.0.11", "patch-package": "^8.0.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-i18next": "16.5.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", @@ -94,7 +94,7 @@ "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.29.7", @@ -102,7 +102,7 @@ "@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", @@ -567,7 +567,7 @@ "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.14", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.14" }, "peerDependencies": { "@tanstack/react-query": "^5.100.14", "react": "^18 || ^19" } }, "sha512-lQSnbJva85o7jGcJiIDrA8s3VGGx9zaBCgAljm0H1QcScU2iaDYnPuRLg/xI0k0dC45pgg9RTvpgJx5iVHRsjA=="], @@ -599,7 +599,7 @@ "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], - "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], @@ -1139,7 +1139,7 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], - "i18next": ["i18next@25.10.10", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ=="], + "i18next": ["i18next@26.3.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1213,7 +1213,7 @@ "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], - "jotai": ["jotai@2.16.2", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-DH0lBiTXvewsxtqqwjDW6Hg9JPTDnq9LcOsXSFWCAUEt+qj5ohl9iRVX9zQXPPHKLXCdH+5mGvM28fsXMl17/g=="], + "jotai": ["jotai@2.20.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -1281,7 +1281,7 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], @@ -1537,7 +1537,7 @@ "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], - "react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="], + "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], @@ -1913,7 +1913,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], @@ -2005,8 +2005,6 @@ "@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], - "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], diff --git a/package.json b/package.json index f785937ea..ca7b49a0d 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,10 @@ "@react-native-community/netinfo": "^12.0.0", "@react-navigation/native": "^7.2.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": "~56.0.6", "expo-application": "~56.0.3", @@ -72,14 +72,14 @@ "expo-system-ui": "~56.0.5", "expo-task-manager": "~56.0.15", "expo-web-browser": "~56.0.5", - "i18next": "^25.0.0", - "jotai": "2.16.2", - "lodash": "4.17.23", + "i18next": "^26.3.0", + "jotai": "2.20.0", + "lodash": "4.18.1", "nativewind": "^2.0.11", "patch-package": "^8.0.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-i18next": "16.5.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", @@ -115,7 +115,7 @@ "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.29.7", @@ -123,7 +123,7 @@ "@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", From 3c7292b73bbf922bcaab860e061b9b1b45ae38c0 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 00:50:57 +0200 Subject: [PATCH 265/309] 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] --- app.json | 1 + plugins/withSwiftUICoreWeakLink.js | 55 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 plugins/withSwiftUICoreWeakLink.js diff --git a/app.json b/app.json index 3326f4e4a..833850547 100644 --- a/app.json +++ b/app.json @@ -132,6 +132,7 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], + ["./plugins/withSwiftUICoreWeakLink.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/withSwiftUICoreWeakLink.js b/plugins/withSwiftUICoreWeakLink.js new file mode 100644 index 000000000..3f7bd3cf3 --- /dev/null +++ b/plugins/withSwiftUICoreWeakLink.js @@ -0,0 +1,55 @@ +const { withPodfile } = require("expo/config-plugins"); + +// iOS 26 / Xcode 26: some pods (Liquid Glass / SwiftUI-based, e.g. +// react-native-glass-effect-view, @expo/ui) pull in SwiftUICore, which cannot +// be linked directly ("not an allowed client of it"). Weak-linking SwiftUICore +// on the app target(s) satisfies the linker. iOS-only; tvOS is unaffected. +const PATCH_START = "## >>> swiftuicore weak link"; +const PATCH_END = "## <<< swiftuicore weak link"; + +function buildPatch() { + return [ + PATCH_START, + " installer.aggregate_targets.each do |aggregate_target|", + " aggregate_target.user_project.native_targets.each do |target|", + " target.build_configurations.each do |cfg|", + " flags = cfg.build_settings['OTHER_LDFLAGS'] || '$(inherited)'", + " flags = flags.join(' ') if flags.is_a?(Array)", + " unless flags.include?('SwiftUICore')", + " cfg.build_settings['OTHER_LDFLAGS'] = flags + ' -weak_framework SwiftUICore'", + " end", + " end", + " end", + " aggregate_target.user_project.save", + " end", + PATCH_END, + ].join("\n"); +} + +module.exports = function withSwiftUICoreWeakLink(config) { + return withPodfile(config, (config) => { + let podfile = config.modResults.contents; + + if (!/^\s*post_install\s+do\s+\|installer\|/m.test(podfile)) { + podfile += "\n\npost_install do |installer|\nend\n"; + } + + const patch = buildPatch(); + + if (podfile.includes(PATCH_START)) { + podfile = podfile.replace( + new RegExp(`${PATCH_START}[\\s\\S]*?${PATCH_END}`), + patch, + ); + } else { + podfile = podfile.replace( + /^\s*post_install\s+do\s+\|installer\|.*$/m, + (match) => `${match}\n\n${patch}`, + ); + } + + console.log("✅ withSwiftUICoreWeakLink: Podfile updated"); + config.modResults.contents = podfile; + return config; + }); +}; From 1e9c9fb67f01a9a6ddb69a537191435cf9d87bfa Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 01:27:57 +0200 Subject: [PATCH 266/309] 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] --- app.json | 1 - plugins/withSwiftUICoreWeakLink.js | 55 ------------------------------ 2 files changed, 56 deletions(-) delete mode 100644 plugins/withSwiftUICoreWeakLink.js diff --git a/app.json b/app.json index 833850547..3326f4e4a 100644 --- a/app.json +++ b/app.json @@ -132,7 +132,6 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withSwiftUICoreWeakLink.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/withSwiftUICoreWeakLink.js b/plugins/withSwiftUICoreWeakLink.js deleted file mode 100644 index 3f7bd3cf3..000000000 --- a/plugins/withSwiftUICoreWeakLink.js +++ /dev/null @@ -1,55 +0,0 @@ -const { withPodfile } = require("expo/config-plugins"); - -// iOS 26 / Xcode 26: some pods (Liquid Glass / SwiftUI-based, e.g. -// react-native-glass-effect-view, @expo/ui) pull in SwiftUICore, which cannot -// be linked directly ("not an allowed client of it"). Weak-linking SwiftUICore -// on the app target(s) satisfies the linker. iOS-only; tvOS is unaffected. -const PATCH_START = "## >>> swiftuicore weak link"; -const PATCH_END = "## <<< swiftuicore weak link"; - -function buildPatch() { - return [ - PATCH_START, - " installer.aggregate_targets.each do |aggregate_target|", - " aggregate_target.user_project.native_targets.each do |target|", - " target.build_configurations.each do |cfg|", - " flags = cfg.build_settings['OTHER_LDFLAGS'] || '$(inherited)'", - " flags = flags.join(' ') if flags.is_a?(Array)", - " unless flags.include?('SwiftUICore')", - " cfg.build_settings['OTHER_LDFLAGS'] = flags + ' -weak_framework SwiftUICore'", - " end", - " end", - " end", - " aggregate_target.user_project.save", - " end", - PATCH_END, - ].join("\n"); -} - -module.exports = function withSwiftUICoreWeakLink(config) { - return withPodfile(config, (config) => { - let podfile = config.modResults.contents; - - if (!/^\s*post_install\s+do\s+\|installer\|/m.test(podfile)) { - podfile += "\n\npost_install do |installer|\nend\n"; - } - - const patch = buildPatch(); - - if (podfile.includes(PATCH_START)) { - podfile = podfile.replace( - new RegExp(`${PATCH_START}[\\s\\S]*?${PATCH_END}`), - patch, - ); - } else { - podfile = podfile.replace( - /^\s*post_install\s+do\s+\|installer\|.*$/m, - (match) => `${match}\n\n${patch}`, - ); - } - - console.log("✅ withSwiftUICoreWeakLink: Podfile updated"); - config.modResults.contents = podfile; - return config; - }); -}; From f8414194f09deccb6afe7574344a870840b89683 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 08:01:09 +0200 Subject: [PATCH 267/309] 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] --- app.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app.json b/app.json index 3326f4e4a..6caffc47a 100644 --- a/app.json +++ b/app.json @@ -78,8 +78,7 @@ "expo-build-properties", { "ios": { - "deploymentTarget": "16.4", - "useFrameworks": "static" + "deploymentTarget": "16.4" }, "android": { "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], From a3ed822bf49fa751485119d991876c9e12d1c9d9 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 08:20:05 +0200 Subject: [PATCH 268/309] test(ios): use_modular_headers! for legacy pods after dropping useFrameworks Without useFrameworks:static, old Obj-C pods (react-native-udp -> ) lose the React umbrella header. use_modular_headers! restores module header maps so 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] --- app.json | 1 + plugins/withModularHeaders.js | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 plugins/withModularHeaders.js diff --git a/app.json b/app.json index 6caffc47a..083b9c602 100644 --- a/app.json +++ b/app.json @@ -131,6 +131,7 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], + ["./plugins/withModularHeaders.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/withModularHeaders.js b/plugins/withModularHeaders.js new file mode 100644 index 000000000..24d22123b --- /dev/null +++ b/plugins/withModularHeaders.js @@ -0,0 +1,24 @@ +const { withPodfile } = require("expo/config-plugins"); + +// Without `useFrameworks: static`, older Obj-C pods that do `#import ` +// (e.g. react-native-udp's UdpSockets.m -> ) can't resolve the +// React umbrella. `use_modular_headers!` restores module-style header maps for all +// pods so resolves again. Inserted at the top of the main target. +const MARKER = "use_modular_headers! # streamyfin: for legacy pods"; + +module.exports = function withModularHeaders(config) { + return withPodfile(config, (config) => { + let podfile = config.modResults.contents; + + if (!podfile.includes(MARKER)) { + podfile = podfile.replace( + /^(target\s+['"][^'"]+['"]\s+do)\s*$/m, + (match) => `${match}\n ${MARKER}`, + ); + config.modResults.contents = podfile; + console.log("✅ withModularHeaders: use_modular_headers! injected"); + } + + return config; + }); +}; From 0ba3f446151267a96cf15df663ce66d83991a792 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 08:32:21 +0200 Subject: [PATCH 269/309] chore: upgrade Biome to 2.4.16, clean up lint, and fix TV password modal (#1598) --- app/(auth)/(tabs)/(custom-links)/index.tsx | 2 +- app/(auth)/(tabs)/(favorites)/index.tsx | 2 +- app/(auth)/(tabs)/(home)/downloads/index.tsx | 2 +- app/(auth)/(tabs)/(home)/sessions/index.tsx | 4 +-- .../appearance/hide-libraries/page.tsx | 2 +- .../(home)/settings/hide-libraries/page.tsx | 2 +- .../settings/plugins/jellyseerr/page.tsx | 2 +- .../settings/plugins/kefinTweaks/page.tsx | 2 +- .../settings/plugins/marlin-search/page.tsx | 2 +- .../settings/plugins/streamystats/page.tsx | 2 +- .../jellyseerr/company/[companyId].tsx | 2 +- .../jellyseerr/genre/[genreId].tsx | 2 +- .../jellyseerr/person/[personId].tsx | 2 +- .../livetv/channels.tsx | 2 +- .../livetv/guide.tsx | 2 +- .../livetv/recordings.tsx | 2 +- app/(auth)/(tabs)/(search)/index.tsx | 4 +-- app/(auth)/player/direct-player.tsx | 4 +-- biome.json | 2 +- bun.lock | 20 ++++++------ components/PlayButton.tsx | 2 +- components/PlayButton.tv.tsx | 2 +- components/downloads/DownloadCard.tsx | 2 +- components/home/TVHeroCarousel.tsx | 2 +- components/livetv/TVChannelCard.tsx | 2 +- components/livetv/TVLiveTVGuide.tsx | 2 +- components/login/TVPasswordEntryModal.tsx | 8 +++++ .../video-player/controls/Controls.tv.tsx | 31 +++++++++++++++++++ components/video-player/controls/types.ts | 2 +- hooks/usePlaybackManager.ts | 2 +- hooks/useSessions.ts | 4 +-- package.json | 2 +- providers/WebSocketProvider.tsx | 4 +-- utils/streamRanker.ts | 2 +- 34 files changed, 85 insertions(+), 46 deletions(-) diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx index 8dbb18660..b128fc332 100644 --- a/app/(auth)/(tabs)/(custom-links)/index.tsx +++ b/app/(auth)/(tabs)/(custom-links)/index.tsx @@ -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([]); diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx index a3c83c04b..10fffe9d0 100644 --- a/app/(auth)/(tabs)/(favorites)/index.tsx +++ b/app/(auth)/(tabs)/(favorites)/index.tsx @@ -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); diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index fb8ef0b9e..884b1fbb2 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -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); diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index 0ed8fc940..d8a3590d5 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -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; } diff --git a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx index a0b3bab9b..24a3011e3 100644 --- a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx @@ -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); diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index e1c8b56b6..e7a61bde3 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -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); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx index beddd900a..84041fd01 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx index dbbf60994..e9af145fd 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx index 10be4af58..3ce2c81c3 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 697db6c4e..1c4dcd199 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx index fdcd786c9..34d83446c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx index 7ea008085..e8ac35dd7 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx index a29e12809..c94a71bd4 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx index 6c9790b59..98b712acc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx index 390e8eb60..a69318e68 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx @@ -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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx index 9a390162a..cc482c557 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx @@ -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 ( diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index bc431bca7..29461b49a 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -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 []; } diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b5d84ff6d..d14bf21fc 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(null); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -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 = { diff --git a/biome.json b/biome.json index 67ed64e02..6e51af7d5 100644 --- a/biome.json +++ b/biome.json @@ -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": [ "**/*", diff --git a/bun.lock b/bun.lock index 8db5508fa..86e060db7 100644 --- a/bun.lock +++ b/bun.lock @@ -100,7 +100,7 @@ }, "devDependencies": { "@babel/core": "7.28.6", - "@biomejs/biome": "2.3.11", + "@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", @@ -309,23 +309,23 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.2.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-gEnLP7q9Iai0KlVxHDIdlrDgkvJ5vwPzL2+2ucz5BdPWd++Cf5GO1jPq92R4/85PrioviCZnlAD91Wx8WxPOjA=="], diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 5b70ac165..462705d63 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -414,7 +414,7 @@ export const PlayButton: React.FC = ({ ]); 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 diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index 2486f4241..c8b6b76e3 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -78,7 +78,7 @@ export const PlayButton: React.FC = ({ }; 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 diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 66f2a81b1..c67f60583 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -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; } diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 74ae8af37..11339e0c3 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC = ({ // 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]); diff --git a/components/livetv/TVChannelCard.tsx b/components/livetv/TVChannelCard.tsx index 7ac47b71d..95f337db5 100644 --- a/components/livetv/TVChannelCard.tsx +++ b/components/livetv/TVChannelCard.tsx @@ -180,4 +180,4 @@ const styles = StyleSheet.create({ }, }); -export { CARD_WIDTH, CARD_HEIGHT }; +export { CARD_HEIGHT, CARD_WIDTH }; diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx index 7c1f12f64..ad99626f6 100644 --- a/components/livetv/TVLiveTVGuide.tsx +++ b/components/livetv/TVLiveTVGuide.tsx @@ -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", diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 9efa9d0cb..596bb610a 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -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 = ({ 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")); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 2e7fab497..657f2a8cd 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 { @@ -315,6 +316,31 @@ export const Controls: FC = ({ [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 => { + 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, @@ -572,6 +598,9 @@ export const Controls: FC = ({ disableTrack?.setTrack(); }, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, + refreshSubtitleTracks: onRefreshSubtitleTracks + ? refreshSubtitleTracks + : undefined, }); controlsInteractionRef.current(); }, [ @@ -581,6 +610,8 @@ export const Controls: FC = ({ videoContextSubtitleTracks, subtitleIndex, handleLocalSubtitleDownloaded, + onRefreshSubtitleTracks, + refreshSubtitleTracks, ]); const handleToggleTechnicalInfo = useCallback(() => { diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index 30f277aa4..ca2ea1413 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -28,4 +28,4 @@ type Track = { localPath?: string; }; -export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; +export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle }; diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 8387511f5..b4316241a 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -80,7 +80,7 @@ export const usePlaybackManager = ({ const { data: adjacentItems } = useQuery({ queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline], queryFn: async (): Promise => { - if (!item || !item.SeriesId) { + if (!item?.SeriesId) { return null; } diff --git a/hooks/useSessions.ts b/hooks/useSessions.ts index 5aba65159..108441c0e 100644 --- a/hooks/useSessions.ts +++ b/hooks/useSessions.ts @@ -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({ diff --git a/package.json b/package.json index bbd5393d6..c032a35d2 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ }, "devDependencies": { "@babel/core": "7.28.6", - "@biomejs/biome": "2.3.11", + "@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", diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index bb9d1d1ff..41af25cf6 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -122,7 +122,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const handlePlayCommand = useCallback( (data: any) => { - if (!data || !data.ItemIds || !data.ItemIds.length) { + if (!data?.ItemIds?.length) { return; } @@ -150,7 +150,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, [connectWebSocket]); useEffect(() => { - if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) { + if (!deviceId || !api?.accessToken || !isNetworkConnected) { return; } diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts index 8121adea9..242cf950d 100644 --- a/utils/streamRanker.ts +++ b/utils/streamRanker.ts @@ -156,4 +156,4 @@ class StreamRanker { } } -export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker }; +export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker }; From 4cc11403f86f04779b51998633473bcb5665b39b Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 08:36:07 +0200 Subject: [PATCH 270/309] chore(deps): bump Renovate-proven JS dependencies (minors + i18n) (#1599) --- bun.lock | 34 ++++++++++++++++------------------ package.json | 18 +++++++++--------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 86e060db7..c8f2a6926 100644 --- a/bun.lock +++ b/bun.lock @@ -14,14 +14,14 @@ "@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/material-top-tabs": "7.4.28", "@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", @@ -53,14 +53,14 @@ "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", + "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-i18next": "17.0.8", "react-native": "npm:react-native-tvos@0.83.6-0", "react-native-awesome-slider": "^2.9.0", "react-native-bottom-tabs": "1.2.0", @@ -96,7 +96,7 @@ "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", @@ -575,7 +575,7 @@ "@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="], + "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="], "@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="], @@ -609,7 +609,7 @@ "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.14", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.14" }, "peerDependencies": { "@tanstack/react-query": "^5.100.14", "react": "^18 || ^19" } }, "sha512-lQSnbJva85o7jGcJiIDrA8s3VGGx9zaBCgAljm0H1QcScU2iaDYnPuRLg/xI0k0dC45pgg9RTvpgJx5iVHRsjA=="], @@ -1197,7 +1197,7 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], - "i18next": ["i18next@25.10.10", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ=="], + "i18next": ["i18next@26.3.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1285,7 +1285,7 @@ "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], - "jotai": ["jotai@2.16.2", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-DH0lBiTXvewsxtqqwjDW6Hg9JPTDnq9LcOsXSFWCAUEt+qj5ohl9iRVX9zQXPPHKLXCdH+5mGvM28fsXMl17/g=="], + "jotai": ["jotai@2.20.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -1353,7 +1353,7 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], @@ -1603,7 +1603,7 @@ "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], - "react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="], + "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], @@ -1987,7 +1987,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], @@ -2091,8 +2091,6 @@ "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], diff --git a/package.json b/package.json index c032a35d2..f72902bd9 100644 --- a/package.json +++ b/package.json @@ -35,14 +35,14 @@ "@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/material-top-tabs": "7.4.28", "@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", @@ -74,14 +74,14 @@ "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", + "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-i18next": "17.0.8", "react-native": "npm:react-native-tvos@0.83.6-0", "react-native-awesome-slider": "^2.9.0", "react-native-bottom-tabs": "1.2.0", @@ -117,7 +117,7 @@ "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", From 2c58636843995fc5222d1f22c0e22cf9dfc66ef5 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 16:21:43 +0200 Subject: [PATCH 271/309] test(ios): Path A - keep useFrameworks:static + forceStaticLinking ExpoUI/GlassEffectView Pivot: removing useFrameworks fixed SwiftUICore but broke legacy pods (udp ), 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] --- app.json | 5 +++-- plugins/withModularHeaders.js | 24 ------------------------ 2 files changed, 3 insertions(+), 26 deletions(-) delete mode 100644 plugins/withModularHeaders.js diff --git a/app.json b/app.json index 083b9c602..6595cbfb6 100644 --- a/app.json +++ b/app.json @@ -78,7 +78,9 @@ "expo-build-properties", { "ios": { - "deploymentTarget": "16.4" + "deploymentTarget": "16.4", + "useFrameworks": "static", + "forceStaticLinking": ["ExpoUI", "GlassEffectView"] }, "android": { "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], @@ -131,7 +133,6 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withModularHeaders.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/withModularHeaders.js b/plugins/withModularHeaders.js deleted file mode 100644 index 24d22123b..000000000 --- a/plugins/withModularHeaders.js +++ /dev/null @@ -1,24 +0,0 @@ -const { withPodfile } = require("expo/config-plugins"); - -// Without `useFrameworks: static`, older Obj-C pods that do `#import ` -// (e.g. react-native-udp's UdpSockets.m -> ) can't resolve the -// React umbrella. `use_modular_headers!` restores module-style header maps for all -// pods so resolves again. Inserted at the top of the main target. -const MARKER = "use_modular_headers! # streamyfin: for legacy pods"; - -module.exports = function withModularHeaders(config) { - return withPodfile(config, (config) => { - let podfile = config.modResults.contents; - - if (!podfile.includes(MARKER)) { - podfile = podfile.replace( - /^(target\s+['"][^'"]+['"]\s+do)\s*$/m, - (match) => `${match}\n ${MARKER}`, - ); - config.modResults.contents = podfile; - console.log("✅ withModularHeaders: use_modular_headers! injected"); - } - - return config; - }); -}; From b6ea6d4f140deb72842465f84f667b5bfcedae2e Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 16:50:52 +0200 Subject: [PATCH 272/309] 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] --- app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.json b/app.json index 6595cbfb6..ed6ddf194 100644 --- a/app.json +++ b/app.json @@ -80,7 +80,7 @@ "ios": { "deploymentTarget": "16.4", "useFrameworks": "static", - "forceStaticLinking": ["ExpoUI", "GlassEffectView"] + "forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"] }, "android": { "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], From 04e75c81a4f1c008dad72510c154691c2feda86d Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 17:38:01 +0200 Subject: [PATCH 273/309] 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). --- app.json | 1 + plugins/withSwiftUICoreWeakLink.js | 60 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 plugins/withSwiftUICoreWeakLink.js diff --git a/app.json b/app.json index ed6ddf194..0edd20a85 100644 --- a/app.json +++ b/app.json @@ -133,6 +133,7 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], + ["./plugins/withSwiftUICoreWeakLink.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/withSwiftUICoreWeakLink.js b/plugins/withSwiftUICoreWeakLink.js new file mode 100644 index 000000000..8134f3094 --- /dev/null +++ b/plugins/withSwiftUICoreWeakLink.js @@ -0,0 +1,60 @@ +const { withXcodeProject } = require("@expo/config-plugins"); + +// Tokens written verbatim as OTHER_LDFLAGS array entries. +const LDFLAG_TOKENS = ['"-weak_framework"', '"SwiftUICore"']; + +/** + * Xcode 26 + `use_frameworks! :linkage => :static` makes the main app target + * auto-link SwiftUICore directly (SwiftUI was split into SwiftUI + SwiftUICore on + * recent SDKs, and the SwiftUI pods' object files carry a `-framework SwiftUICore` + * autolink directive that flows into the app link). The linker then rejects it: + * ld: cannot link directly with 'SwiftUICore' because product being built is + * not an allowed client of it + * Weakly linking SwiftUICore on the app target bypasses the allowed-client check; + * the symbols still resolve at runtime via SwiftUI's re-export. + * + * Scoped to `com.apple.product-type.application` ONLY — it must not touch the + * tvOS TopShelf app-extension (which legitimately links SwiftUI); applying the + * flag there breaks that target. + */ +const withSwiftUICoreWeakLink = (config) => + withXcodeProject(config, (config) => { + const project = config.modResults; + const nativeTargets = project.pbxNativeTargetSection(); + const configLists = project.pbxXCConfigurationList(); + const buildConfigs = project.pbxXCBuildConfigurationSection(); + + // Collect build-configuration UUIDs that belong to application targets only. + const appConfigIds = new Set(); + for (const key of Object.keys(nativeTargets)) { + const target = nativeTargets[key]; + if (!target || typeof target !== "object" || !target.productType) + continue; + const productType = String(target.productType).replace(/"/g, ""); + if (productType !== "com.apple.product-type.application") continue; + const list = configLists[target.buildConfigurationList]; + if (!list || !list.buildConfigurations) continue; + for (const bc of list.buildConfigurations) appConfigIds.add(bc.value); + } + + for (const id of appConfigIds) { + const entry = buildConfigs[id]; + if (!entry || typeof entry !== "object" || !entry.buildSettings) continue; + const settings = entry.buildSettings; + let flags = settings.OTHER_LDFLAGS; + if (flags == null || flags === '""' || flags === "") { + flags = ['"$(inherited)"']; + } else if (typeof flags === "string") { + flags = [flags]; + } + const already = flags.some((f) => String(f).includes("SwiftUICore")); + if (!already) { + flags.push(...LDFLAG_TOKENS); + settings.OTHER_LDFLAGS = flags; + } + } + + return config; + }); + +module.exports = withSwiftUICoreWeakLink; From 6e223596f621dd134ccf46565903531fea5694fe Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 18:17:02 +0200 Subject: [PATCH 274/309] fix(ios): drop SwiftUICore autolink on pods so the app links via SwiftUI re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app.json | 1 - plugins/with-runtime-framework-headers.js | 11 +++++ plugins/withSwiftUICoreWeakLink.js | 60 ----------------------- scripts/ios/build-ios.ts | 19 +++++-- 4 files changed, 27 insertions(+), 64 deletions(-) delete mode 100644 plugins/withSwiftUICoreWeakLink.js diff --git a/app.json b/app.json index 0edd20a85..ed6ddf194 100644 --- a/app.json +++ b/app.json @@ -133,7 +133,6 @@ ], "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withSwiftUICoreWeakLink.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.js index 97d11b9de..23e7d1011 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.js @@ -25,6 +25,17 @@ function buildPatch() { " cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'", " cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"", " cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'", + " # iOS 26 / Xcode 26: SwiftUI was split into SwiftUI + SwiftUICore. The SwiftUI", + " # pods (ExpoUI, glass-effect, glass-poster, …) emit a `-framework SwiftUICore`", + " # autolink directive that, under use_frameworks :static, flows into the app", + " # executable's link. The app isn't an allowed client of the private", + " # SwiftUICore.tbd → `cannot link directly with 'SwiftUICore'`. Dropping that one", + " # autolink at the Swift frontend lets the symbols resolve via SwiftUI's", + " # re-export instead. Phone-only — tvOS links fine and must stay untouched.", + " if ENV['EXPO_TV'] != '1'", + " cfg.build_settings['OTHER_SWIFT_FLAGS'] ||= '$(inherited)'", + " cfg.build_settings['OTHER_SWIFT_FLAGS'] << ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'", + " end", " end", " end", "", diff --git a/plugins/withSwiftUICoreWeakLink.js b/plugins/withSwiftUICoreWeakLink.js deleted file mode 100644 index 8134f3094..000000000 --- a/plugins/withSwiftUICoreWeakLink.js +++ /dev/null @@ -1,60 +0,0 @@ -const { withXcodeProject } = require("@expo/config-plugins"); - -// Tokens written verbatim as OTHER_LDFLAGS array entries. -const LDFLAG_TOKENS = ['"-weak_framework"', '"SwiftUICore"']; - -/** - * Xcode 26 + `use_frameworks! :linkage => :static` makes the main app target - * auto-link SwiftUICore directly (SwiftUI was split into SwiftUI + SwiftUICore on - * recent SDKs, and the SwiftUI pods' object files carry a `-framework SwiftUICore` - * autolink directive that flows into the app link). The linker then rejects it: - * ld: cannot link directly with 'SwiftUICore' because product being built is - * not an allowed client of it - * Weakly linking SwiftUICore on the app target bypasses the allowed-client check; - * the symbols still resolve at runtime via SwiftUI's re-export. - * - * Scoped to `com.apple.product-type.application` ONLY — it must not touch the - * tvOS TopShelf app-extension (which legitimately links SwiftUI); applying the - * flag there breaks that target. - */ -const withSwiftUICoreWeakLink = (config) => - withXcodeProject(config, (config) => { - const project = config.modResults; - const nativeTargets = project.pbxNativeTargetSection(); - const configLists = project.pbxXCConfigurationList(); - const buildConfigs = project.pbxXCBuildConfigurationSection(); - - // Collect build-configuration UUIDs that belong to application targets only. - const appConfigIds = new Set(); - for (const key of Object.keys(nativeTargets)) { - const target = nativeTargets[key]; - if (!target || typeof target !== "object" || !target.productType) - continue; - const productType = String(target.productType).replace(/"/g, ""); - if (productType !== "com.apple.product-type.application") continue; - const list = configLists[target.buildConfigurationList]; - if (!list || !list.buildConfigurations) continue; - for (const bc of list.buildConfigurations) appConfigIds.add(bc.value); - } - - for (const id of appConfigIds) { - const entry = buildConfigs[id]; - if (!entry || typeof entry !== "object" || !entry.buildSettings) continue; - const settings = entry.buildSettings; - let flags = settings.OTHER_LDFLAGS; - if (flags == null || flags === '""' || flags === "") { - flags = ['"$(inherited)"']; - } else if (typeof flags === "string") { - flags = [flags]; - } - const already = flags.some((f) => String(f).includes("SwiftUICore")); - if (!already) { - flags.push(...LDFLAG_TOKENS); - settings.OTHER_LDFLAGS = flags; - } - } - - return config; - }); - -module.exports = withSwiftUICoreWeakLink; diff --git a/scripts/ios/build-ios.ts b/scripts/ios/build-ios.ts index 1d7bf77c7..33110507b 100644 --- a/scripts/ios/build-ios.ts +++ b/scripts/ios/build-ios.ts @@ -525,10 +525,23 @@ function displayBuildError( console.error(line); } console.error("--- End Build Errors ---\n"); - } else if (stdout.trim()) { + } + + // Linker failures ("Undefined symbols for architecture …", the SwiftUICore + // autolink rejection, "ld: …") don't carry an "error:" token, so the pattern + // filter above drops the symbol name and "referenced from" context that + // actually pinpoints the culprit. Surface that block explicitly. + const stdoutLines = stdout.split("\n"); + const undefIdx = stdoutLines.findIndex((line: string) => + line.includes("Undefined symbols"), + ); + if (undefIdx >= 0) { + console.error("\n--- Linker error detail ---"); + console.error(stdoutLines.slice(undefIdx, undefIdx + 40).join("\n")); + console.error("--- End linker error detail ---\n"); + } else if (errorLines.length === 0 && stdout.trim()) { // No specific error patterns found, show last N lines of stdout - const lines = stdout.split("\n"); - const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n"); + const lastLines = stdoutLines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n"); console.error( `\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`, ); From e4b0161d15f68109b43cd4986e467520697330ff Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 29 May 2026 18:51:52 +0200 Subject: [PATCH 275/309] fix(ios): patch react-native-ios-utilities for RN 0.85 prebuilt React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../react-native-ios-utilities@5.2.0.patch | 28 +++++++++++++++++++ bun.lock | 1 + package.json | 3 +- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 bun-patches/react-native-ios-utilities@5.2.0.patch diff --git a/bun-patches/react-native-ios-utilities@5.2.0.patch b/bun-patches/react-native-ios-utilities@5.2.0.patch new file mode 100644 index 000000000..4659493ba --- /dev/null +++ b/bun-patches/react-native-ios-utilities@5.2.0.patch @@ -0,0 +1,28 @@ +diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644 +--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift ++++ b/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]? { diff --git a/bun.lock b/bun.lock index b9546e2ac..cad38e06e 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,7 @@ }, }, "patchedDependencies": { + "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch", "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", }, diff --git a/package.json b/package.json index ca7b49a0d..bef7d6003 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ ], "patchedDependencies": { "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", - "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch" + "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", + "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch" } } From 07b79de20355112b4c21a1f13c68aa5ec5e39a1f Mon Sep 17 00:00:00 2001 From: lostb1t Date: Sat, 30 May 2026 09:11:59 +0200 Subject: [PATCH 276/309] fix: Do not cache background request for mediasources (#1602) --- .../items/page.tsx | 6 ++++-- app/_layout.tsx | 5 +++-- hooks/useItemQuery.ts | 7 +++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx index d61072177..b62761791 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -30,8 +30,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(() => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 79278b702..dd7d9e964 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -375,8 +375,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 + ); }, }, }} diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts index 370b5f35a..d7616b8cc 100644 --- a/hooks/useItemQuery.ts +++ b/hooks/useItemQuery.ts @@ -12,11 +12,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); @@ -53,5 +59,6 @@ export const useItemQuery = ( refetchOnWindowFocus: true, refetchOnReconnect: true, networkMode: "always", + ...queryOptions, }); }; From 0f86c776ba0aa3cb45e538b913b0bf50296e1ba9 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Sat, 30 May 2026 09:22:23 +0200 Subject: [PATCH 277/309] feat(player): add chapter markers and chapter list (#1586) Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com> --- components/DownloadItem.tsx | 28 ++- components/chapters/ChapterList.tsx | 196 ++++++++++++++++++ components/chapters/ChapterTicks.tsx | 87 ++++++++ .../video-player/controls/BottomControls.tsx | 82 +++++++- components/video-player/controls/Controls.tsx | 4 + .../video-player/controls/TrickplayBubble.tsx | 73 +++++-- .../controls/hooks/useVideoSlider.ts | 16 ++ translations/en.json | 6 + utils/chapters.test.ts | 138 ++++++++++++ utils/chapters.ts | 97 +++++++++ 10 files changed, 708 insertions(+), 19 deletions(-) create mode 100644 components/chapters/ChapterList.tsx create mode 100644 components/chapters/ChapterTicks.tsx create mode 100644 utils/chapters.test.ts create mode 100644 utils/chapters.ts diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e50b4efca..0d2b8ffbb 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -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"; @@ -195,9 +196,30 @@ export const DownloadItems: React.FC = ({ ); } 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, @@ -206,7 +228,7 @@ export const DownloadItems: React.FC = ({ const downloadDetails = await getDownloadUrl({ api, - item, + item: itemForDownload, userId: user.Id!, mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, @@ -218,7 +240,7 @@ export const DownloadItems: React.FC = ({ return { url: downloadDetails?.url, - item, + item: itemForDownload, mediaSource: downloadDetails?.mediaSource, }; }); diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx new file mode 100644 index 000000000..42a90b89e --- /dev/null +++ b/components/chapters/ChapterList.tsx @@ -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>(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; 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 ( + + + e.stopPropagation()} style={styles.sheet}> + + {t("chapters.title")} + + + + + `${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 ( + { + onSeek(positionMs); + onClose(); + }} + style={[ + styles.row, + isActive && { backgroundColor: `${Colors.primary}33` }, + ]} + > + + {item.chapter.Name || + t("chapters.chapter_number", { number: index + 1 })} + + + {formatChapterTime(positionMs)} + + + ); + }} + /> + + + + ); +} + +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, + }, +}); diff --git a/components/chapters/ChapterTicks.tsx b/components/chapters/ChapterTicks.tsx new file mode 100644 index 000000000..850c63bf0 --- /dev/null +++ b/components/chapters/ChapterTicks.tsx @@ -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 ( + + {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 ( + + ); + })} + + ); +} + +export const ChapterTicks = memo(ChapterTicksComponent); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c7..af445d373 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -1,18 +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, 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; @@ -38,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: { @@ -61,6 +79,8 @@ interface BottomControlsProps { export const BottomControls: FC = ({ item, + chapters, + durationMs, showControls, isSliding, showRemoteBubble, @@ -84,12 +104,38 @@ export const BottomControls: FC = ({ handleSliderChange, handleTouchStart, handleTouchEnd, + seekTo, trickPlayUrl, trickplayInfo, time, }) => { 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 ( = ({ {item?.Type === "Audio" && ( {item?.Album} )} + {currentChapterName ? ( + + {currentChapterName} + + ) : null} - + + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center mr-4' + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + + )} = ({ height: 10, justifyContent: "center", alignItems: "stretch", + // 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} @@ -203,6 +268,7 @@ export const BottomControls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={time} + chapterName={scrubChapterName} /> ) } @@ -212,6 +278,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + = ({ /> + setChapterListVisible(false)} + /> ); }; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 96dfad6b3..a13c59a56 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -251,6 +251,7 @@ export const Controls: FC = ({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, } = useVideoSlider({ progress, isSeeking, @@ -528,6 +529,8 @@ export const Controls: FC = ({ > = ({ handleSliderChange={handleSliderChange} handleTouchStart={handleTouchStart} handleTouchEnd={handleTouchEnd} + seekTo={seekTo} trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2e..ea126a120 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -22,12 +22,15 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Chapter name at the scrubbed position, if any. */ + chapterName?: string | null; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + chapterName, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,18 +39,30 @@ export const TrickplayBubble: FC = ({ 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}`; + + // Slightly larger preview than before (scale 1.6 vs old 1.4) to give the + // overlay text more room and feel closer to the Jellyfin web style. + const previewScale = 1.6; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: previewScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' @@ -75,17 +90,51 @@ export const TrickplayBubble: FC = ({ 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. + */} + + {chapterName ? ( + + {chapterName} + + ) : null} + + {timeStr} + + - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} - ); }; diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index dfc1164bb..3c19ce7ad 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -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, }; } diff --git a/translations/en.json b/translations/en.json index 3fe9efb66..dfad3bc06 100644 --- a/translations/en.json +++ b/translations/en.json @@ -610,6 +610,12 @@ "downloaded_file_no": "No", "downloaded_file_cancel": "Cancel" }, + "chapters": { + "title": "Chapters", + "chapter_number": "Chapter {{number}}", + "open": "Open chapters", + "close": "Close chapters" + }, "item_card": { "next_up": "Next Up", "no_items_to_display": "No Items to Display", diff --git a/utils/chapters.test.ts b/utils/chapters.test.ts new file mode 100644 index 000000000..875bc7e2a --- /dev/null +++ b/utils/chapters.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { + chapterMarkers, + chapterNameAt, + chapterStartsMs, + currentChapterIndex, + formatChapterTime, + sortedChapters, +} from "./chapters"; + +// Helper: a ChapterInfo with a start in milliseconds. +const ch = (ms: number, name?: string) => ({ + StartPositionTicks: ms * 10000, + Name: name, +}); + +describe("chapterMarkers", () => { + test("maps chapters to position + percent", () => { + expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + { positionMs: 30_000, percent: 25 }, + { positionMs: 60_000, percent: 50 }, + ]); + }); + + test("drops chapters past the duration", () => { + expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("returns [] when duration is 0 or chapters missing", () => { + expect(chapterMarkers([ch(0)], 0)).toEqual([]); + expect(chapterMarkers(null, 120_000)).toEqual([]); + expect(chapterMarkers(undefined, 120_000)).toEqual([]); + }); + + test("excludes a chapter exactly at the duration", () => { + expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("skips chapters with no StartPositionTicks", () => { + expect( + chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000), + ).toEqual([{ positionMs: 30_000, percent: 25 }]); + }); +}); + +describe("currentChapterIndex", () => { + const chapters = [ch(0), ch(30_000), ch(60_000)]; + test("returns the chapter containing the position", () => { + expect(currentChapterIndex(0, chapters)).toBe(0); + expect(currentChapterIndex(15_000, chapters)).toBe(0); + expect(currentChapterIndex(30_000, chapters)).toBe(1); + expect(currentChapterIndex(90_000, chapters)).toBe(2); + }); + test("returns -1 before the first chapter and for no chapters", () => { + expect(currentChapterIndex(-5, chapters)).toBe(-1); + expect(currentChapterIndex(10_000, [])).toBe(-1); + expect(currentChapterIndex(10_000, null)).toBe(-1); + }); +}); + +describe("sortedChapters", () => { + test("pairs each chapter with its ms start, sorted ascending", () => { + const a = ch(60_000, "C"); + const b = ch(0, "A"); + const c = ch(30_000, "B"); + expect(sortedChapters([a, b, c])).toEqual([ + { chapter: b, positionMs: 0 }, + { chapter: c, positionMs: 30_000 }, + { chapter: a, positionMs: 60_000 }, + ]); + }); + test("returns [] for null/undefined", () => { + expect(sortedChapters(null)).toEqual([]); + expect(sortedChapters(undefined)).toEqual([]); + }); +}); + +describe("chapterStartsMs", () => { + test("returns sorted ms positions", () => { + expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([ + 0, 30_000, 60_000, + ]); + }); + + test("skips entries without StartPositionTicks", () => { + expect( + chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]), + ).toEqual([0, 30_000]); + }); + + test("returns [] for null/undefined/empty", () => { + expect(chapterStartsMs(null)).toEqual([]); + expect(chapterStartsMs(undefined)).toEqual([]); + expect(chapterStartsMs([])).toEqual([]); + }); +}); + +describe("chapterNameAt", () => { + const named = [ + { StartPositionTicks: 0, Name: "Intro" }, + { StartPositionTicks: 30_000 * 10000, Name: "Action" }, + { StartPositionTicks: 60_000 * 10000, Name: "Outro" }, + ]; + + test("returns the chapter name for the active position", () => { + expect(chapterNameAt(0, named)).toBe("Intro"); + expect(chapterNameAt(15_000, named)).toBe("Intro"); + expect(chapterNameAt(45_000, named)).toBe("Action"); + expect(chapterNameAt(90_000, named)).toBe("Outro"); + }); + + test("returns null before the first chapter", () => { + expect(chapterNameAt(-1, named)).toBeNull(); + }); + + test("returns null for null/undefined/empty chapters", () => { + expect(chapterNameAt(10_000, null)).toBeNull(); + expect(chapterNameAt(10_000, undefined)).toBeNull(); + expect(chapterNameAt(10_000, [])).toBeNull(); + }); + + test("returns null when the active chapter has no Name", () => { + expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull(); + }); +}); + +describe("formatChapterTime", () => { + test("formats m:ss and h:mm:ss", () => { + expect(formatChapterTime(65_000)).toBe("1:05"); + expect(formatChapterTime(3_725_000)).toBe("1:02:05"); + expect(formatChapterTime(-100)).toBe("0:00"); + }); +}); diff --git a/utils/chapters.ts b/utils/chapters.ts new file mode 100644 index 000000000..8b0e0e7bc --- /dev/null +++ b/utils/chapters.ts @@ -0,0 +1,97 @@ +/** + * Pure helpers for Jellyfin chapter markers. Dependency-free so they are + * unit-testable under `bun test`. + */ + +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { ticksToMs } from "@/utils/time"; + +export interface ChapterMarker { + /** Chapter start, in milliseconds. */ + positionMs: number; + /** Chapter start as a percentage (0-100) of the media duration. */ + percent: number; +} + +export interface ChapterEntry { + chapter: ChapterInfo; + /** Chapter start, in milliseconds. */ + positionMs: number; +} + +/** Chapters paired with their millisecond start, sorted ascending by start. */ +export const sortedChapters = ( + chapters: ChapterInfo[] | null | undefined, +): ChapterEntry[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((chapter) => ({ + chapter, + positionMs: ticksToMs(chapter.StartPositionTicks), + })) + .sort((a, b) => a.positionMs - b.positionMs); + +/** Chapter start positions in milliseconds, ascending. */ +export const chapterStartsMs = ( + chapters: ChapterInfo[] | null | undefined, +): number[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((c) => ticksToMs(c.StartPositionTicks)) + .sort((a, b) => a - b); + +/** Chapter markers within [0, durationMs]; empty when duration is unknown. */ +export const chapterMarkers = ( + chapters: ChapterInfo[] | null | undefined, + durationMs: number, +): ChapterMarker[] => { + if (durationMs <= 0) return []; + return chapterStartsMs(chapters) + .filter((ms) => ms >= 0 && ms < durationMs) + .map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 })); +}; + +/** Index of the chapter containing `positionMs`, or -1 if before the first. */ +export const currentChapterIndex = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): number => { + const starts = chapterStartsMs(chapters); + let index = -1; + for (let i = 0; i < starts.length; i++) { + if (positionMs >= starts[i]) index = i; + else break; + } + return index; +}; + +/** Name of the chapter containing `positionMs`, or null if none / unnamed. */ +export const chapterNameAt = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): string | null => { + // Sort once, derive both the active index and the entry from the same array + // — `chapterNameAt` runs on every playback tick, so paying for one `sort()` + // instead of two is worth the duplication of the index loop here. + const sorted = sortedChapters(chapters); + let idx = -1; + for (let i = 0; i < sorted.length; i++) { + if (positionMs >= sorted[i].positionMs) idx = i; + else break; + } + if (idx < 0) return null; + const name = sorted[idx]?.chapter.Name; + return name && name.length > 0 ? name : null; +}; + +/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */ +export const formatChapterTime = (positionMs: number): string => { + const total = Math.max(0, Math.floor(positionMs / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(seconds)}` + : `${minutes}:${pad(seconds)}`; +}; From 1cabbf087e351f956a0d850500f523ffb9ef9c34 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Sat, 30 May 2026 10:03:19 +0200 Subject: [PATCH 278/309] 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> --- components/tv/TVNextEpisodeCountdown.tsx | 8 ++++-- .../video-player/controls/Controls.tv.tsx | 17 ++++++++++- .../controls/hooks/useRemoteControl.ts | 28 +++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 6582cacb8..47193b0e1 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC = ({ 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 = ({ 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 = ({ // 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}%`, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 657f2a8cd..2faecbaaf 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -517,6 +517,8 @@ export const Controls: FC = ({ 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); @@ -960,6 +962,16 @@ export const Controls: FC = ({ 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, @@ -976,6 +988,8 @@ export const Controls: FC = ({ onVerticalDpad: handleVerticalDpad, onHideControls: hideControls, onBack: handleBack, + onWillExit: handleWillExit, + onCancelExit: handleCancelExit, videoTitle: item?.Name ?? undefined, }); @@ -1061,6 +1075,7 @@ export const Controls: FC = ({ goToNextItemRef.current = goToNextItem; const handleAutoPlayFinish = useCallback(() => { + if (exitingRef.current) return; goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); @@ -1135,7 +1150,7 @@ export const Controls: FC = ({ nextItem={nextItem} api={api} show={isCountdownActive} - isPlaying={isPlaying} + isPlaying={isPlaying && !isExiting} onFinish={handleAutoPlayFinish} onPlayNext={handleNextItemButton} controlsVisible={showControls} diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 359b4100d..8a4fd902e 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -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; @@ -72,6 +76,8 @@ export function useRemoteControl({ onLongSeekRightStart, onLongSeekStop, onVerticalDpad, + onWillExit, + onCancelExit, }: UseRemoteControlProps) { // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(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 }, ], ); From 0e531da2e0538055d03bb76f21e4c8499dee9ab8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:03:55 +0200 Subject: [PATCH 279/309] fix(watchlists): invalidate watchlists list query on add/remove --- hooks/useWatchlistMutations.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hooks/useWatchlistMutations.ts b/hooks/useWatchlistMutations.ts index e3e39ef96..5e65ebf99 100644 --- a/hooks/useWatchlistMutations.ts +++ b/hooks/useWatchlistMutations.ts @@ -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], }); From 407ef3f51e38f41a145b55bf6e6689b2153eac14 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:03:55 +0200 Subject: [PATCH 280/309] chore: add skills to gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2b3bd6ed7..386732fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,6 @@ modules/background-downloader/android/build/* /modules/mpv-player/android/build # ios:unsigned-build Artifacts -build/ \ No newline at end of file +build/ +.agents/skills/** +skills-lock.json From ab0957044ffc4f2f7da4072ab383e330426d1e26 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:03:56 +0200 Subject: [PATCH 281/309] fix: use correct back button --- app/(auth)/(tabs)/(home)/_layout.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index a7e059edc..302ed0836 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -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: () => ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> Date: Sat, 30 May 2026 10:03:56 +0200 Subject: [PATCH 282/309] fix: use correct back button --- app/(auth)/(tabs)/(home)/_layout.tsx | 146 +++------------------------ 1 file changed, 15 insertions(+), 131 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 302ed0836..475920bb4 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -59,15 +59,7 @@ export default function IndexLayout() { headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, - headerLeft: () => ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( @@ -329,11 +217,7 @@ export default function IndexLayout() { name='collections/[collectionId]' options={{ title: "", - headerLeft: () => ( - _router.back()} className='pl-0.5'> - - - ), + headerLeft: () => , headerShown: true, headerBlurEffect: "prominent", headerTransparent: Platform.OS === "ios", From 62c86533b1e220f0dd9483c91f887ad091b8b70f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:03:56 +0200 Subject: [PATCH 283/309] fix(settings): preserve app defaults for unlocked plugin values --- utils/atoms/settings.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 28f7d1b46..f12f091c7 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -341,6 +341,14 @@ export const pluginSettingsAtom = atom( loadPluginSettings(), ); +const hasMeaningfulSettingValue = (value: unknown) => + value !== undefined && value !== null && value !== ""; + +const getEffectiveSettingValue = ( + settings: Partial | null | undefined, + settingsKey: K, +) => settings?.[settingsKey] ?? defaultValues[settingsKey]; + export const useSettings = () => { const api = useAtomValue(apiAtom); const [_settings, setSettings] = useAtom(settingsAtom); @@ -381,12 +389,13 @@ export const useSettings = () => { for (const [key, setting] of Object.entries(newPluginSettings)) { if (setting && !setting.locked && setting.value !== undefined) { const settingsKey = key as keyof Settings; - // Apply if forceOverride is true, or if user hasn't explicitly set this value - if ( - forceOverride || - _settings[settingsKey] === undefined || - _settings[settingsKey] === "" - ) { + const effectiveValue = getEffectiveSettingValue( + _settings, + settingsKey, + ); + // Apply if forceOverride is true, or if neither persisted settings + // nor app defaults provide a meaningful value. + if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) { (updates as any)[settingsKey] = setting.value; } } @@ -438,28 +447,22 @@ export const useSettings = () => { // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. // If admin sets locked to false but provides a value, - // use user settings first and fallback on admin setting if required. + // use persisted settings first, then app defaults, and only fallback on the + // plugin value when neither provides a meaningful value. const settings: Settings = useMemo(() => { - const unlockedPluginDefaults: Partial = {}; const overrideSettings = Object.entries(pluginSettings ?? {}).reduce< Partial >((acc, [key, setting]) => { if (setting) { const { value, locked } = setting; const settingsKey = key as keyof Settings; - - // Make sure we override default settings with plugin settings when they are not locked. - if ( - !locked && - value !== undefined && - _settings?.[settingsKey] !== value - ) { - (unlockedPluginDefaults as any)[settingsKey] = value; - } + const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); (acc as any)[settingsKey] = locked ? value - : (_settings?.[settingsKey] ?? value); + : hasMeaningfulSettingValue(effectiveValue) + ? effectiveValue + : value; } return acc; }, {}); From 769c7a24324f75fc767522f8c4ab93629ff38838 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:13:40 +0200 Subject: [PATCH 284/309] fix: restore nested dropdown sections for expo 55 on iOS using nested ContextMenus --- components/PlatformDropdown.tsx | 50 +++++++++------ components/search/DiscoverFilters.tsx | 88 +++++++++++++++++++-------- 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 24fd135c7..ec664a167 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,4 +1,4 @@ -import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; +import { Button, ContextMenu, Host } from "@expo/ui/swift-ui"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect } from "react"; @@ -254,23 +254,39 @@ const PlatformDropdownComponent = ({ // Otherwise render as individual buttons if (radioOptions.length > 0) { if (group.title) { - // Use Picker for grouped options + // Use a nested ContextMenu as a submenu for grouped options + const selectedOption = radioOptions.find( + (opt) => opt.selected, + ); + const displayTitle = selectedOption + ? `${group.title}: ${selectedOption.label}` + : group.title; + items.push( - opt.label)} - variant='menu' - selectedIndex={radioOptions.findIndex( - (opt) => opt.selected, - )} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - const selectedOption = radioOptions[index]; - selectedOption?.onPress(); - onOptionSelect?.(selectedOption?.value); - }} - />, + + + + + + {radioOptions.map((option, optionIndex) => ( + + ))} + + , ); } else { // Render radio options as direct buttons diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 2e844c881..b4cf35df0 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -1,4 +1,4 @@ -import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; +import { Button, ContextMenu, Host } from "@expo/ui/swift-ui"; import { Platform, View } from "react-native"; import { FilterButton } from "@/components/filters/FilterButton"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; @@ -49,32 +49,66 @@ export const DiscoverFilters: React.FC = ({ > - - t(`home.settings.plugins.jellyseerr.order_by.${item}`), - )} - 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, - ); - }} - /> - t(`library.filters.${item}`))} - variant='menu' - selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - setJellyseerrSortOrder(orderOptions[index]); - }} - /> + + + + + + {sortOptions.map((item) => { + const label = t( + `home.settings.plugins.jellyseerr.order_by.${item}`, + ); + const isSelected = + jellyseerrOrderBy === + (item as unknown as JellyseerrSearchSort); + return ( + + ); + })} + + + + + + + + {orderOptions.map((item) => { + const label = t(`library.filters.${item}`); + const isSelected = jellyseerrSortOrder === item; + return ( + + ); + })} + + From 6fe464088b4813ae1e058ee392185216a8c34876 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:40:10 +0200 Subject: [PATCH 285/309] 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. --- modules/mpv-player/ios/MPVLayerRenderer.swift | 25 ++++++++++++------- modules/mpv-player/ios/PiPController.swift | 10 ++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index e6686a815..7b20c990e 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -220,20 +220,27 @@ final class MPVLayerRenderer { statusObservation?.invalidate() statusObservation = nil - queue.sync { [weak self] in - guard let self, let handle = self.mpv else { return } - - mpv_set_wakeup_callback(handle, nil, nil) - mpv_terminate_destroy(handle) + if let handle = self.mpv { self.mpv = nil + // Remove the wakeup callback synchronously while `self` is still + // alive so it can never fire against a deallocated instance. + mpv_set_wakeup_callback(handle, nil, nil) + // Destroy mpv OFF the main thread. mpv_terminate_destroy() blocks + // until all mpv threads (including the vo_avfoundation output thread) + // are joined, and that teardown needs the main run loop to finish. + // Calling it via queue.sync from deinit (main thread) deadlocks/freezes + // the UI. queue.async only references `handle`, never `self`. + queue.async { + mpv_terminate_destroy(handle) + } } - DispatchQueue.main.async { [weak self] in - guard let self else { return } + let layer = self.displayLayer + DispatchQueue.main.async { if #available(iOS 18.0, *) { - self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) + layer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) } else { - self.displayLayer.flushAndRemoveImage() + layer.flushAndRemoveImage() } } diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift index 7a58cb38e..6ad0bec51 100644 --- a/modules/mpv-player/ios/PiPController.swift +++ b/modules/mpv-player/ios/PiPController.swift @@ -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 From 37b51abd341e51eacb306108c49ef1bfba997e97 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:41:10 +0200 Subject: [PATCH 286/309] chore: deps --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index b1202b9c5..e57d0ece3 100644 --- a/bun.lock +++ b/bun.lock @@ -1686,7 +1686,7 @@ "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], - "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="], + "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], From 0bf8fac07908184e58ef0a2445ca4a89afee0bf8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 10:41:24 +0200 Subject: [PATCH 287/309] chore: update pr template --- .github/pull_request_template.md | 95 ++++++++++---------------------- 1 file changed, 29 insertions(+), 66 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e76ebb70d..95978bab3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,91 +1,54 @@ - # 📦 Pull Request -## 🔖 Summary + + + +## 📝 Description ## 🏷️ Ticket / Issue -## 🛠️ What’s Changed - - -- 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 (1–2 lines) ---> - -## 📋 Details - - -### ⚠️ Breaking Changes - - -### 🔐 Security & Privacy Impact - - -### ⚡ Performance Impact - - ### 🖼️ Screenshots / GIFs (if UI) - + ## ✅ Checklist - [ ] I’ve 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 -## ⚙️ Deployment Notes - - -## 📝 Additional Notes - \ No newline at end of file From fe4d90df2663bb7b83edae4da28b080a14068498 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 11:50:03 +0200 Subject: [PATCH 288/309] fix(dropdown): make all stacked dropdowns visible in download sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @expo/ui's (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. --- components/PlatformDropdown.tsx | 36 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index b9c006a6c..d7d02d274 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -262,24 +262,28 @@ const PlatformDropdownComponent = ({ }, [isVisible, controlledOpen, controlledOnOpenChange]); if (Platform.OS === "ios") { + // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) + // fills its parent and reports its own size via setStyleSize, so it can't + // size itself to content. If the wrapper has no size, the Host's `flex: 1` + // height depends on the parent while the parent depends on the Host — a + // circular dependency that collapses to 0 for any selector nested more than + // one level deep (so only the first, shallowest dropdown stays visible). + // Giving the wrapper the measured size breaks the cycle; the Host then + // fills a concrete box. return ( - - {/* Hidden measurer: lays the trigger out normally to capture its - intrinsic size, which we then pin onto the Host below. */} - - - {trigger} - - - + {/* Hidden measurer: lays the trigger out off-flow to capture its + intrinsic size. Absolutely positioned WITHOUT right/bottom so it + sizes to the trigger's content rather than to its parent. */} + + {trigger} + +