From 358e00d8b71f321bceb5575d5026979d52e8a449 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Jan 2026 22:11:35 +0100 Subject: [PATCH 01/93] fix(player): resolve tvOS freeze on player exit by reordering mpv options --- modules/mpv-player/ios/MPVLayerRenderer.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index deb58b5e..e2c4573a 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -167,16 +167,17 @@ 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 + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) + // Hardware decoding with VideoToolbox // On simulator, use software decoding since VideoToolbox is not available // On device, use VideoToolbox with software fallback enabled #if targetEnvironment(simulator) checkError(mpv_set_option_string(handle, "hwdec", "no")) #else - // Only enable composite OSD mode on real device (OSD is not supported in simulator). - // This renders subtitles directly onto video frames using the GPU, which is better for PiP since subtitles are baked into the video. - checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) - checkError(mpv_set_option_string(handle, "hwdec", "videotoolbox")) #endif checkError(mpv_set_option_string(handle, "hwdec-codecs", "all")) From d07a521f60e9f8d075c25f0fe6908f28899554b7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 23 Jan 2026 21:23:00 +0100 Subject: [PATCH 02/93] feat(tv): add trickplay bubble positioning aligned with progress bar --- .../video-player/controls/Controls.tv.tsx | 152 +++++++++++++++--- .../video-player/controls/TrickplayBubble.tsx | 33 ++-- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 01f4ad81..55985978 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -13,7 +13,12 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, TVFocusGuideView, View } from "react-native"; +import { + StyleSheet, + TVFocusGuideView, + useWindowDimensions, + View, +} from "react-native"; import Animated, { Easing, type SharedValue, @@ -82,6 +87,96 @@ interface Props { const TV_SEEKBAR_HEIGHT = 14; const TV_AUTO_HIDE_TIMEOUT = 5000; +// Trickplay bubble positioning constants +const TV_TRICKPLAY_SCALE = 2; +const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5; +const TV_TRICKPLAY_BUBBLE_WIDTH = + TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_RIGHT_PADDING = 150; +const TV_TRICKPLAY_FADE_DURATION = 200; + +interface TVTrickplayBubbleProps { + trickPlayUrl: { + x: number; + y: number; + url: string; + } | null; + trickplayInfo: { + aspectRatio?: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + } | null; + time: { + hours: number; + minutes: number; + seconds: number; + }; + progress: SharedValue; + max: SharedValue; + progressBarWidth: number; + visible: boolean; +} + +const TVTrickplayBubblePositioned: FC = ({ + trickPlayUrl, + trickplayInfo, + time, + progress, + max, + progressBarWidth, + visible, +}) => { + const opacity = useSharedValue(0); + + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: TV_TRICKPLAY_FADE_DURATION, + easing: Easing.out(Easing.quad), + }); + }, [visible, opacity]); + + const minX = TV_TRICKPLAY_INTERNAL_OFFSET; + const maxX = + progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH + + TV_TRICKPLAY_INTERNAL_OFFSET + + TV_TRICKPLAY_RIGHT_PADDING; + + const animatedStyle = useAnimatedStyle(() => { + const progressPercent = max.value > 0 ? progress.value / max.value : 0; + + const xPosition = Math.max( + minX, + Math.min( + maxX, + progressPercent * progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH / 2 + + TV_TRICKPLAY_CENTERING_OFFSET, + ), + ); + + return { + transform: [{ translateX: xPosition }], + opacity: opacity.value, + }; + }); + + return ( + + + + ); +}; + export const Controls: FC = ({ item, seek, @@ -112,7 +207,15 @@ export const Controls: FC = ({ transcodeReasons, }) => { const insets = useSafeAreaInsets(); + const { width: screenWidth } = useWindowDimensions(); const { t } = useTranslation(); + + // Calculate progress bar width (matches the padding used in bottomInner) + const progressBarWidth = useMemo(() => { + const leftPadding = Math.max(insets.left, 48); + const rightPadding = Math.max(insets.right, 48); + return screenWidth - leftPadding - rightPadding; + }, [screenWidth, insets.left, insets.right]); const api = useAtomValue(apiAtom); const { settings } = useSettings(); const router = useRouter(); @@ -831,15 +934,17 @@ export const Controls: FC = ({ }, ]} > - {showSeekBubble && ( - - - - )} + + + {/* Same padding as TVFocusableProgressBar for alignment */} @@ -963,15 +1068,17 @@ export const Controls: FC = ({ )} - {showSeekBubble && ( - - - - )} + + + {/* Bidirectional focus guides - stacked together per docs */} {/* Downward: play button → progress bar */} @@ -1063,12 +1170,15 @@ const styles = StyleSheet.create({ }, trickplayBubbleContainer: { position: "absolute", - bottom: 120, + bottom: 170, left: 0, right: 0, - alignItems: "center", zIndex: 20, }, + trickplayBubblePositioned: { + position: "absolute", + bottom: 0, + }, focusGuide: { height: 1, width: "100%", diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2..416bb92c 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -4,6 +4,10 @@ import { View } from "react-native"; import { Text } from "@/components/common/Text"; import { CONTROLS_CONSTANTS } from "./constants"; +const BASE_IMAGE_SCALE = 1.4; +const BUBBLE_LEFT_OFFSET = 62; +const BUBBLE_WIDTH_MULTIPLIER = 1.5; + interface TrickplayBubbleProps { trickPlayUrl: { x: number; @@ -22,12 +26,21 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Scale factor for the image (default 1). Does not affect timestamp text. */ + imageScale?: number; +} + +function formatTime(hours: number, minutes: number, seconds: number): string { + const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); + const prefix = hours > 0 ? `${hours}:` : ""; + return `${prefix}${pad(minutes)}:${pad(seconds)}`; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + imageScale = 1, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,16 +49,17 @@ export const TrickplayBubble: FC = ({ const { x, y, url } = trickPlayUrl; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + const finalScale = BASE_IMAGE_SCALE * imageScale; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: finalScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' > = ({ contentFit='cover' /> - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} + + {formatTime(time.hours, time.minutes, time.seconds)} ); From 0e3e8b80167b6cb0ea545540230eba40f3d7877b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 24 Jan 2026 10:05:22 +0100 Subject: [PATCH 03/93] Revert "fix: scaling" This reverts commit 566ff485fb4e758cf9f909ce879cd2834dd0b1b4. --- 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, 38 insertions(+), 163 deletions(-) diff --git a/bun.lock b/bun.lock index 27360de8..dcbcbb95 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "streamyfin", @@ -77,7 +78,6 @@ "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,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.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=="], @@ -804,10 +802,6 @@ "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=="], @@ -1246,8 +1240,6 @@ "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=="], @@ -1326,8 +1318,6 @@ "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=="], @@ -1696,8 +1686,6 @@ "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=="], @@ -2292,8 +2280,6 @@ "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=="], @@ -2446,8 +2432,6 @@ "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=="], @@ -2994,30 +2978,6 @@ "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=="], @@ -3202,26 +3162,6 @@ "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=="], @@ -3258,42 +3198,6 @@ "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=="], @@ -3306,18 +3210,6 @@ "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 05ae5c5a..03e9296d 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -14,7 +14,6 @@ import { TouchableOpacity, View, } from "react-native"; -import { fontSize, size } from "react-native-responsive-sizes"; import { useHaptic } from "@/hooks/useHaptic"; import { Loader } from "./Loader"; @@ -141,15 +140,11 @@ export const Button: React.FC> = ({ }} > - + {children} diff --git a/components/common/Text.tsx b/components/common/Text.tsx index 6c6fee71..739177d7 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 a575b3e4..40c2b8d3 100644 --- a/components/login/TVInput.tsx +++ b/components/login/TVInput.tsx @@ -6,7 +6,6 @@ import { TextInput, type TextInputProps, } from "react-native"; -import { fontSize, size } from "react-native-responsive-sizes"; interface TVInputProps extends TextInputProps { label?: string; @@ -59,8 +58,8 @@ export const TVInput: React.FC = ({ @@ -70,8 +69,8 @@ export const TVInput: React.FC = ({ allowFontScaling={false} style={[ { - height: size(200), - fontSize: fontSize(12), + height: 68, + fontSize: 24, color: "#FFFFFF", }, style, diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 1d8aab0d..2b4348d8 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -13,7 +13,6 @@ 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"; @@ -67,7 +66,7 @@ const TVBackButton: React.FC<{ setIsFocused(false); animateFocus(false); }} - style={{ alignSelf: "flex-start", marginBottom: size(40) }} + style={{ alignSelf: "flex-start", marginBottom: 40 }} disabled={disabled} focusable={!disabled} > @@ -76,25 +75,26 @@ const TVBackButton: React.FC<{ transform: [{ scale }], flexDirection: "row", alignItems: "center", - paddingHorizontal: size(12), - borderRadius: size(8), + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, backgroundColor: isFocused ? "rgba(168, 85, 247, 0.2)" : "transparent", - borderWidth: size(2), + borderWidth: 2, borderColor: isFocused ? Colors.primary : "transparent", }} > {label} @@ -463,7 +463,7 @@ export const TVLogin: React.FC = () => { flexGrow: 1, justifyContent: "center", alignItems: "center", - paddingVertical: size(20), + paddingVertical: 60, }} showsVerticalScrollIndicator={false} > @@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => { style={{ width: "100%", maxWidth: 800, - paddingHorizontal: size(40), + paddingHorizontal: 60, }} > {/* Back Button */} @@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => { {/* Title */} {serverName ? ( @@ -501,18 +501,16 @@ 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 1b182dd4..772ea094 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 9fdbd0db..ef7bcae7 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 59f9c640..1bb168a8 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 79293394..ec58f0d6 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 00000000..f0e34c75 --- /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 1719df96..46f25523 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 21b41ff6..49c43cef 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 f9074213..84409617 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 fcca2498..7abf10fb 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 2a629267..855d1cf6 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 21ac3577..3038199b 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 10/93] 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 f0e34c75..a5f57038 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 11/93] 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 3f0734fa..4f1bbfbd 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 b8a31190..2ee4592c 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 9f2152c5..695c64cc 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 3ae0e214..fe2ab9f6 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 eb69f7f8..81b9772b 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 00000000..1c334190 --- /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 00000000..3b9b9b19 --- /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 00000000..77c5efb8 --- /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 00000000..8878779b --- /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 00000000..58ac4a25 --- /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 00000000..0ec104f5 --- /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 00000000..eee2be16 --- /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 e026be73..d93e9077 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 12/93] 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 999d6037..c8cde42c 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 13/93] 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 86183d47..964bd9e5 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 14/93] 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 6b106937..e8186434 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 15/93] 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 72142a18..44efc01c 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 16/93] 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 1463713d..8110d1d5 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 2ee4592c..05620423 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 09b46cc5..b9285e65 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 05b9ca8c..b1117e6f 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 bc83eafa..29f1cb30 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 c8cde42c..9c4dfe0d 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 772ea094..1f7ae86b 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 ef7bcae7..c8f7879e 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 1bb168a8..e2c5ddd7 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 70918762..ef607b74 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 695c64cc..eea3836b 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 888da829..aec682e9 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 828ca3f6..f1f1276b 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 24e3ec84..7b4b712c 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 b2ccab2e..4698644e 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 2030d109..ef1ea6cc 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 342caac9..41359561 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 6e088664..33813775 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 c421f573..d545985b 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 e1b7106f..7ec27d09 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 9df832f1..77a8ddf9 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 52978caa..07f879ce 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 c50a6518..3522f711 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 5d87e697..ad6dded5 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 ec3fb43b..c00ff16b 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 855d1cf6..c435ea30 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 3038199b..c3f9ba81 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 17/93] 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 8110d1d5..10ad9029 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 44efc01c..2c483671 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 c435ea30..53901aa3 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 c3f9ba81..aa4ac0c8 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 18/93] 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 c00ff16b..a2ac3b80 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 19/93] 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 41cd249b..d7f951e2 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 20/93] 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 6583731e..64830da5 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 21/93] 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 d7f951e2..4f467b9a 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 c0b923ce..a5739151 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 22/93] 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 6184c72e..412759f0 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 483be971..8788e617 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 23/93] 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 1f7ae86b..1b9ee9dd 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 24/93] 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 10ad9029..5f2afc8f 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 aca3b452..1a6ca0b7 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 7a363268..02bc671e 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 05620423..0adee973 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 e2c5ddd7..1c5e69a4 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 b731ab98..4f543be8 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 9d9bdcfa..ed047543 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 79071868..125d9d3e 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 e015bec5..05663b32 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 8788e617..a9d52daf 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 25/93] 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 4f467b9a..7caffb0b 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 1fb22993..60039346 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 26/93] 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 d2f3e32c..5295adef 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 27/93] 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 7caffb0b..21738e97 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 60039346..262dc323 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 00000000..bb32f7c3 --- /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 64830da5..72d26c44 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 28/93] 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 21738e97..0494f108 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 255a114d..ad292639 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 fba1dbe1..bfbfb526 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 29/93] 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 e50b4efc..0923dfec 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 5217e541..4869a75e 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 a336143e..1f1b37cc 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 30/93] 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 964bd9e5..e11daa07 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 10e4f559..9fd32e75 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 072b2f93..28cc2f9d 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 812d084d..f1471e3a 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 548dfed5..53b1e2ed 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 bc12201b..4c985dc0 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 00000000..b88f32df --- /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 00000000..2880cbed --- /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 05663b32..3f88d031 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 31/93] 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 00000000..7ac47b71 --- /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 00000000..f93beb35 --- /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 b88f32df..fffa7738 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 dad6c082..18369ab4 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 3f88d031..b3c6963f 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 32/93] 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 00000000..73ba7491 --- /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 00000000..a3ca8348 --- /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 00000000..7c1f12f6 --- /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 33/93] 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 fffa7738..a3f3ed45 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 34/93] 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 00000000..5188c54e --- /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 00000000..e8287132 --- /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 35/93] 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 5f2afc8f..84cd7da2 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 13c88a92..17fa7506 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 00000000..6df37412 --- /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 af55826f..a6f00fd1 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 b60a3d40..c85c7fa3 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 69c6d272..1dd2555f 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 23f86093..c700cb82 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 b3c6963f..6e016a12 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 aa4ac0c8..59aea126 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 36/93] 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 a6f00fd1..52b3afec 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 37/93] 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 84cd7da2..b9a0849c 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 17fa7506..3155849b 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 7167f7ba..1745beed 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 0ceae68b..c715cbe7 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 5b631ec4..7b6713b3 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 38/93] 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 3155849b..00f3e74f 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 c2124720..35572718 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 39/93] 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 2ee23479..deedf8d4 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 e11daa07..ab5d6eec 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 00000000..269b51f1 --- /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 00000000..4409db06 --- /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 00000000..b9575cd7 --- /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 00000000..45d5f31a --- /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 00000000..48603cd0 --- /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 00000000..418f862a --- /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 00000000..7dfb2017 --- /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 00000000..eda49ef0 --- /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 00000000..f36a1837 --- /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 00000000..d52dca9b --- /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 00000000..24ca01fc --- /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 00000000..41652a52 --- /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 00000000..7663a609 --- /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 00000000..eaa0d84d --- /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 00000000..6f9b234a --- /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 00000000..e9ddc0c8 --- /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 00000000..c6c837d5 --- /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 00000000..36e8f2d8 --- /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 9fd32e75..0c037d42 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 40/93] 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 72d26c44..ef16f0f6 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 41/93] 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 ef16f0f6..ff533f02 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 42/93] 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 0494f108..ae91ca6a 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 43/93] 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 4599bcc4..ad2ca991 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 17ce1773..f73eb907 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 78d3c3c8..bb9d1d1f 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 44/93] 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 1a6ca0b7..749d8508 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 02bc671e..9fe9733c 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 831d96c1..5c486866 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 0adee973..07b03b17 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 ae91ca6a..acc2f583 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 1b9ee9dd..770597c2 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 1c5e69a4..07ba09b2 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 96640773..c8a9cf0b 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 9293789e..527bd74d 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 4f543be8..eacfd7b1 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 79d7c379..feba7c2d 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 f9eca844..310da15e 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 262dc323..af1d8353 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 ff533f02..e6440e1d 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 1a0b7653..337cbc2a 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 00000000..8ab8e4bb --- /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 14e71b2d..8352ba76 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 00000000..3c547c0d --- /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 6e016a12..bcc2c03d 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 45/93] 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 b9a0849c..3c92b8af 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 e6440e1d..2dfe4044 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 00000000..93be4dfb --- /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 8352ba76..527ef22e 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 00000000..753b3e64 --- /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 d4750406..f231e2bf 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 bcc2c03d..722a105c 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 59aea126..63b2ee16 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 46/93] 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 eacfd7b1..f7062c61 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 47/93] 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 753b3e64..0a9b1dcc 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 48/93] 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 722a105c..18a6fbbd 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 49/93] 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 9fe9733c..2ac4c58e 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 2c85e094..cc40d2dc 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 4c985dc0..8bcff9da 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 50/93] 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 00000000..a38dd751 --- /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 ad2ca991..9b824133 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 00000000..24a9637c --- /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 03e9296d..3b5d6351 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 40c2b8d3..2e9435f1 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 25d9ce74..821bf689 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 1473cf86..3e5574a6 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 1903709e..cc9ad1ff 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 a1c7d55c..39f4fb8c 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 85ccc3f1..fc843256 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 b75b91ac..4325cdd6 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 00000000..f0da43f1 --- /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 00000000..3cafa61e --- /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 00000000..38d99e83 --- /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 51/93] 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 18369ab4..bf4d2eed 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 52/93] 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 d2f8ff1e..ae806f31 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 41359561..562bd634 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 53/93] 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 ef1ea6cc..6462b659 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 bf4d2eed..42b584a0 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 54/93] 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 abaaa82e..1ed67eda 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 2ac4c58e..ccf38d3e 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 07b03b17..c649bdf6 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 fe41c147..00000000 --- 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 e92ea676..b4bfb73a 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 07ba09b2..983cae57 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 c8a9cf0b..b72d120d 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 527bd74d..0c318905 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 f7062c61..cab9d566 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 ed047543..00000000 --- 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 125d9d3e..00000000 --- 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 5c8192f4..cba3a554 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 8e15eec0..61b47d64 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 d0d5f857..d15d43ce 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 af1d8353..00000000 --- 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 5e48e9c1..0f271320 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 aec682e9..29817512 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 f1f1276b..95c5f3d1 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 00000000..87a2db73 --- /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 33813775..5414dde7 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 3522f711..a2a3e565 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 06f661db..cec24162 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 5295adef..132b75a6 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 00000000..20c38daa --- /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 77c5efb8..b68cdb97 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 57/93] 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 b68cdb97..3efa9d4c 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 58/93] 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 e33e4444..8d6a4888 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 51abf68c..e4f26492 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 1f1b37cc..de6326ea 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 42b584a0..69b91790 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 08b234ac..cfb31759 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 00000000..00d3330c --- /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 59/93] 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 00f3e74f..5725c56c 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 00000000..f735e7d5 --- /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 527ef22e..76a6e60e 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 69b91790..81ab1542 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 c813d141..f30fda23 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 18a6fbbd..78edbee0 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 60/93] 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 770597c2..6ce36e82 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 b4bfb73a..0b4e194b 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 61/93] 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 a9d52daf..97554931 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 62/93] 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 3c92b8af..3f7adc14 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 63/93] 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 6ce36e82..edea23d3 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 d27bc4f6..c8259838 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 64/93] 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 3f7adc14..9e5b794d 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 00000000..1478b0f7 --- /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 a7847180..a3dc6240 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 76a6e60e..a35104eb 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 07f879ce..0166f99b 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 00000000..a0b0a944 --- /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 4ad040de..97ba07e0 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 78edbee0..651a1aa3 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 00000000..2df72df1 --- /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 65/93] 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 0c037d42..357616b0 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 a3dc6240..43fe2186 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 ec8be5a5..a8a8e6bd 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 63b4efe6..efd1cc49 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 00000000..111c706a --- /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 00000000..7d3cefe1 --- /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 00000000..17f8fe89 --- /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 00000000..8cbc08d9 --- /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 ddd83aa0..8af6a1de 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 821bf689..415cf2cf 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 3e5574a6..e4f0c358 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 cc9ad1ff..00000000 --- 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 4325cdd6..00000000 --- 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 00000000..bfad0baf --- /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 00000000..feff46df --- /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 00000000..cbfd67cf --- /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 00000000..2c1a0c3a --- /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 00000000..a1b57e9b --- /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 00000000..97db7ac5 --- /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 3be0f5d6..3bc61ed7 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 f0da43f1..00000000 --- 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 651a1aa3..bd25ba87 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 00000000..c5d25479 --- /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 00000000..c9532a7f --- /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 3cafa61e..9fd8bf20 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 38d99e83..00000000 --- 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 66/93] 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 bfad0baf..1ef01c51 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 2c1a0c3a..d9f74b37 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 97ba07e0..97f7a4e2 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 00000000..89e51f6a --- /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 f64a56d7..bb5f7713 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 67/93] 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 8af6a1de..182d25d9 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 97f7a4e2..1b066ba3 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 68/93] 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 1dd2555f..a0655803 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 69/93] 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 ccdbc842..86f36755 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 70/93] 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 668c6163..f9dfbb6d 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 43fe2186..cd6b6de5 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 182d25d9..0453ed9c 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 00000000..6b34d793 --- /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 00000000..a0672c72 --- /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 00000000..648af143 --- /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 00000000..1885a811 --- /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 00000000..69d7880f --- /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 71/93] feat(tv): local build eas creds --- .gitignore | 2 ++ eas.json | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b8c7526a..47fcc0cd 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 5ecd93c3..0a17c22a 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 72/93] 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 47fcc0cd..e7f89813 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 5725c56c..b9c1b8ab 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 1c3fd46f..5b70ac16 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 e3718a33..fe1d39f3 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 df140d03..f8eb2629 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 223c73c1..a63353b2 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 35572718..8fe02df0 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 00000000..9ec48ada --- /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 43489710..00000000 --- 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 ec74f4b6..9d7224ff 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 73/93] 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 a0672c72..b6789782 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 1885a811..8a3d2a71 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 74/93] 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 69d7880f..0cb2f8e8 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 75/93] 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 2dfe4044..83c1401a 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 76/93] 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 0b4e194b..c3e1aa34 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 0c318905..8118245b 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 29817512..fba7ce1c 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 c8259838..6cb4fa82 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 1c334190..d2d654c5 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 3efa9d4c..8c8e4f5f 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 58ac4a25..20c2714f 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 77/93] 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 9e5b794d..7a32fc9c 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 b76d39f0..b3418c8c 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 00000000..d76a55f7 --- /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 bd25ba87..49cdc180 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 63b2ee16..31540ccb 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 78/93] 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 b9c1b8ab..2f40a2d2 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 d76a55f7..2c47ada6 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 79/93] 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 2f40a2d2..49fc16b6 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 80/93] 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 7a32fc9c..9dd56673 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 008e1be2..251a6ca3 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 0b1fedc7..7cf5f43f 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 0453ed9c..2ddfac78 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 1b066ba3..ebdaaca1 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 81/93] 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 83c1401a..ce179c0d 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 82/93] 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 d1101892..ec4f8199 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 ce179c0d..3d904e95 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 83/93] 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 357616b0..eb2ae87e 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 84/93] 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 49fc16b6..f210ed95 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 f735e7d5..3e53d0f0 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 f30fda23..513c8dd7 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 85/93] 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 f210ed95..49fc16b6 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 86/93] fix: no log --- app/_layout.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/_layout.tsx b/app/_layout.tsx index ca249891..7dd8abf7 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 87/93] 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 49cdc180..b4c63420 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 97554931..f356a528 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 88/93] 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 9c7b7c4e..dd5ca106 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 89/93] 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 7dd8abf7..ac900896 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 90/93] 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 c715cbe7..4ef2a800 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 52b3afec..4d0f9577 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 c85c7fa3..08665e28 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 a0655803..5fdf5a97 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 c700cb82..552cbefa 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 ad3fcdfa..cec13b0f 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 b4c63420..e5352295 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 f356a528..2c95fe77 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 31540ccb..f7dbf791 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 91/93] 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 49fc16b6..7caeba24 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 1745beed..e597a782 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 f9e4578b..02498d37 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 ec9ca995..7c575084 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 5ec03edd..30f277aa 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 bc5b83e7..b101aeee 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 00000000..69d17a9f --- /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 d9101cf8..23059198 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 92/93] 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 c0da2220..b76a7fe8 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 edea23d3..dbeccca2 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 c3e1aa34..ce32656d 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 b72d120d..a35da259 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 feba7c2d..69c7fc21 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 93/93] 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 8118245b..0d5b6bac 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 */} +