Compare commits

..

6 Commits

Author SHA1 Message Date
Lance Chant
1f324f8d46 Adding QR code login
Added the ability to login to the TV via the mobile app
Fixed some other login issues with back button presses not working

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-15 14:08:32 +02:00
Lance Chant
c012bd44bd Performance improvments for android playback
Ensured the correct hardware encoding is used for android TV versions
Fixed scaling of the hero layout

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-13 13:50:40 +02:00
Lance Chant
4be540fe3c Adding 32 bit support for android tv's
We need to add "armeabi-v7a" to support most of the android tv devices

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-13 10:41:36 +02:00
Lance Chant
bab11addee Attempt 2 at scaling
Added some more logic for scaling to hopefully have a uniform state

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-04-10 15:59:50 +02:00
Lance Chant
8ee1197186 Merge branch 'feat/tv-interface' into feat/tv-interface-uniform-scale
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-04-10 14:45:56 +02:00
Lance Chant
8c21054d33 fix scaling
Attempt 2 at trying to make a uniform scale across apple and android tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-26 09:34:08 +02:00
26 changed files with 1470 additions and 115 deletions

View File

@@ -6,6 +6,14 @@ module.exports = ({ config }) => {
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set

View File

@@ -85,7 +85,7 @@
"useFrameworks": "static"
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",

View File

@@ -96,6 +96,25 @@ export default function IndexLayout() {
),
}}
/>
<Stack.Screen
name='companion-login'
options={{
title: t("companion_login.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/playback-controls/page'
options={{

View File

@@ -0,0 +1,7 @@
import { Platform } from "react-native";
import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
export default function CompanionLoginPage() {
if (Platform.isTV) return null;
return <CompanionLoginScreen />;
}

View File

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

View File

@@ -134,7 +134,7 @@ export default function TabLayout() {
tabBarItemHidden: !Platform.isTV,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>

BIN
assets/icons/gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -30,6 +30,7 @@
"expo-blur": "~15.0.8",
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "^55.0.18",
"expo-constants": "18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
@@ -77,6 +78,7 @@
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.6.0",
@@ -621,6 +623,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
@@ -761,6 +765,8 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"barcode-detector": ["barcode-detector@3.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="],
@@ -929,6 +935,8 @@
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -1019,6 +1027,8 @@
"expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="],
"expo-camera": ["expo-camera@55.0.18", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA=="],
"expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
"expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="],
@@ -1573,7 +1583,7 @@
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -1617,6 +1627,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
@@ -1685,6 +1697,8 @@
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
"react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
"react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="],
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
@@ -1889,6 +1903,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
@@ -1901,6 +1917,8 @@
"test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="],
"text-encoding": ["text-encoding@0.7.0", "", {}, "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -2045,6 +2063,8 @@
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="],
"@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -2409,12 +2429,16 @@
"parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
@@ -2423,6 +2447,8 @@
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
@@ -2489,6 +2515,8 @@
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
@@ -2981,6 +3009,14 @@
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3165,6 +3201,12 @@
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -3201,6 +3243,10 @@
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -3213,6 +3259,12 @@
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
}
}

View File

@@ -0,0 +1,500 @@
import { Camera, CameraView } from "expo-camera";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
KeyboardAvoidingView,
Linking,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button } from "@/components/Button";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService";
type ScreenState =
| "scanning"
| "no-permission"
| "confirm"
| "form"
| "sending"
| "success"
| "error";
interface ParsedPairingCode {
code: string;
}
export const CompanionLoginScreen: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [screenState, setScreenState] = useState<ScreenState>("scanning");
const [_hasPermission, setHasPermission] = useState<boolean | null>(null);
const [pairingCode, setPairingCode] = useState<string>("");
const [serverUrl, setServerUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Pre-fill server URL and username from current session
useEffect(() => {
if (api?.basePath) {
setServerUrl(api.basePath);
}
if (user?.Name) {
setUsername(user.Name);
}
}, [api?.basePath, user?.Name]);
// Request camera permission
useEffect(() => {
Camera.getCameraPermissionsAsync().then((response) => {
setHasPermission(response.granted);
if (!response.granted) {
Camera.requestCameraPermissionsAsync().then((result) => {
setHasPermission(result.granted);
if (!result.granted) {
setScreenState("no-permission");
}
});
}
});
}, []);
const validateAndParseQR = useCallback(
(data: string): ParsedPairingCode | null => {
try {
const parsed = JSON.parse(data);
if (
parsed.action === "streamyfin-pair" &&
typeof parsed.code === "string" &&
parsed.code.length > 0
) {
return { code: parsed.code };
}
return null;
} catch {
return null;
}
},
[],
);
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (screenState !== "scanning") return;
const parsed = validateAndParseQR(data);
if (!parsed) {
setErrorMessage(t("companion_login.error_invalid_qr"));
setScreenState("error");
return;
}
setPairingCode(parsed.code);
// If user is logged in, show confirmation screen (still needs password)
// Otherwise, go straight to the full form
if (user?.Name && api?.basePath) {
setScreenState("confirm");
} else {
setScreenState("form");
}
},
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
);
const handleSendCredentials = useCallback(async () => {
if (
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode
) {
return;
}
setScreenState("sending");
try {
await sendCredentialsToTV(
pairingCode,
serverUrl.trim(),
username.trim(),
password,
);
setScreenState("success");
} catch {
setErrorMessage(t("companion_login.error_generic"));
setScreenState("error");
}
}, [pairingCode, serverUrl, username, password, t]);
const handleScanAgain = useCallback(() => {
setPairingCode("");
setErrorMessage(null);
setPassword("");
setScreenState("scanning");
}, []);
const handleDone = useCallback(() => {
router.back();
}, [router]);
const handleUseDifferentUser = useCallback(() => {
setUsername("");
setPassword("");
setScreenState("form");
}, []);
const handleEnterCodeManually = useCallback(() => {
setScreenState("form");
}, []);
if (screenState === "no-permission") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_permission_denied")}
</Text>
{Platform.OS === "ios" && (
<TouchableOpacity
onPress={() => Linking.openSettings()}
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
>
<Text className='text-base font-semibold text-white'>
Open Settings
</Text>
</TouchableOpacity>
)}
<Button
onPress={handleDone}
color='white'
className='mt-4'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "success") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.success_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.pairing_tv_connecting")}
</Text>
<Button
onPress={handleDone}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "error") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{errorMessage}
</Text>
<View className='mt-4 flex-row gap-3'>
<Button
onPress={handleScanAgain}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleDone}
color='white'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
</View>
);
}
if (screenState === "sending") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='text-xl text-white'>
{t("companion_login.authorizing")}
</Text>
</View>
</View>
);
}
if (screenState === "confirm") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 24,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-center text-2xl font-bold text-white'>
{t("companion_login.login_as", { username })}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.on_server", {
server: serverUrl.replace(/^https?:\/\//, ""),
})}
</Text>
<View className='mb-6 items-center'>
<Text className='mb-1 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
{pairingCode}
</Text>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
autoFocus
/>
</View>
<View className='mt-2'>
<Button
onPress={handleSendCredentials}
disabled={!password.trim()}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
<View className='mt-6 items-center'>
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
<Text className='text-base text-gray-400 underline'>
{t("companion_login.use_different_user")}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
<Text className='text-sm text-gray-500 underline'>
{t("companion_login.scan_again")}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
if (screenState === "form") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 14,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-2xl font-bold text-white'>
{t("companion_login.pairing_enter_credentials")}
</Text>
<Text className='mb-1 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
{pairingCode}
</Text>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.server")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={serverUrl}
onChangeText={setServerUrl}
placeholder={t("server.server_url_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
keyboardType='url'
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.username_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={username}
onChangeText={setUsername}
placeholder={t("login.username_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
/>
</View>
<View className='flex-row justify-center gap-3'>
<Button
onPress={handleScanAgain}
color='black'
className='w-40 border border-neutral-700 bg-neutral-800'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleSendCredentials}
disabled={
!serverUrl.trim() || !username.trim() || !password.trim()
}
className='w-40'
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
return (
<View className='flex-1 bg-black items-center justify-center'>
{/* Camera full screen */}
<CameraView
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
{/* Dark overlay */}
<View className='absolute inset-0 bg-black/60' />
{/* Center scan area */}
<View className='items-center'>
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
<Text className='mt-6 text-center text-base text-white'>
{t("companion_login.align_qr")}
</Text>
<TouchableOpacity
onPress={handleEnterCodeManually}
className='mt-4 px-5 py-2'
>
<Text className='text-sm text-gray-400 underline'>
{t("companion_login.enter_code_manually")}
</Text>
</TouchableOpacity>
</View>
</View>
);
};

View File

@@ -20,7 +20,10 @@ import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
PixelRatio,
Platform,
ScrollView,
View,
} from "react-native";
@@ -40,11 +43,12 @@ import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = scaleSize(100);
// Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = 24;
const SECTION_GAP = scaleSize(10);
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -79,6 +83,22 @@ export const Home = () => {
const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal();
// Log TV viewport dimensions for DPI scaling debug
useEffect(() => {
const w = Dimensions.get("window");
const s = Dimensions.get("screen");
console.log("========== TV DIMENSIONS ==========");
console.log("Platform.OS:", Platform.OS, "isTV:", Platform.isTV);
console.log("Window:", w.width, "x", w.height);
console.log("Screen:", s.width, "x", s.height);
console.log("PixelRatio:", PixelRatio.get());
console.log(
"scaleSize(210):",
210 * Math.min(w.width / 1920, w.height / 1080),
);
console.log("====================================");
}, []);
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -24,9 +24,10 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
const SCALE_PADDING = scaleSize(20);
interface Props extends ViewProps {
title?: string | null;
@@ -81,7 +82,7 @@ const TVSeeAllCard: React.FC<{
style={{
width,
aspectRatio,
borderRadius: 24,
borderRadius: scaleSize(24),
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
@@ -91,9 +92,9 @@ const TVSeeAllCard: React.FC<{
>
<Ionicons
name='arrow-forward'
size={32}
size={scaleSize(32)}
color='white'
style={{ marginBottom: 8 }}
style={{ marginBottom: scaleSize(8) }}
/>
<Text
style={{
@@ -250,7 +251,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginBottom: scaleSize(20),
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
@@ -286,8 +287,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
<View
@@ -329,19 +330,27 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (
@@ -350,7 +359,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
marginLeft: itemWidth / 2,
marginRight: ITEM_GAP,
justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315,
height:
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}}
>
<ActivityIndicator size='small' color='white' />

View File

@@ -19,10 +19,11 @@ import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const SCALE_PADDING = 20;
const SCALE_PADDING = scaleSize(20);
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
@@ -168,8 +169,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>
@@ -286,12 +287,12 @@ export const StreamystatsPromotedWatchlists: React.FC<
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: 16,
width: 128,
height: scaleSize(16),
width: scaleSize(128),
backgroundColor: "#262626",
borderRadius: 4,
borderRadius: scaleSize(4),
marginLeft: SCALE_PADDING,
marginBottom: 16,
marginBottom: scaleSize(16),
}}
/>
<View
@@ -309,8 +310,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>

View File

@@ -18,6 +18,7 @@ import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
@@ -220,8 +221,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
backgroundColor: "#262626",
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>

View File

@@ -33,6 +33,7 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -129,7 +130,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={24}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
@@ -154,15 +155,15 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
style={{
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
borderRadius: scaleSize(24),
overflow: "hidden",
transform: [{ scale }],
borderWidth: 2,
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
shadowRadius: focused ? scaleSize(20) : 0,
}}
>
{posterUrl ? (
@@ -183,7 +184,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
>
<Ionicons
name='film-outline'
size={48}
size={scaleSize(48)}
color='rgba(255,255,255,0.3)'
/>
</View>
@@ -472,7 +473,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
bottom:
40 + sizes.posters.episode * (9 / 16) + sizes.gaps.small * 2 + 20,
scaleSize(40) +
sizes.posters.episode * (9 / 16) +
sizes.gaps.small * 2 +
scaleSize(20),
}}
>
{/* Logo or Title */}
@@ -480,9 +484,9 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
height: scaleSize(100),
width: SCREEN_WIDTH * 0.35,
marginBottom: 16,
marginBottom: scaleSize(16),
}}
contentFit='contain'
contentPosition='left'
@@ -493,7 +497,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -507,7 +511,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.9)",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -521,7 +525,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.8)",
marginBottom: 16,
marginBottom: scaleSize(16),
maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4,
}}
@@ -536,7 +540,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 16,
gap: scaleSize(16),
}}
>
{year && (
@@ -562,10 +566,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
{activeItem?.OfficialRating && (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
paddingHorizontal: scaleSize(8),
paddingVertical: scaleSize(2),
borderRadius: scaleSize(4),
borderWidth: scaleSize(1),
borderColor: "rgba(255,255,255,0.5)",
}}
>
@@ -584,15 +588,15 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
gap: scaleSize(6),
}}
>
<View
style={{
width: 60,
height: 4,
width: scaleSize(60),
height: scaleSize(4),
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: 2,
borderRadius: scaleSize(2),
overflow: "hidden",
}}
>
@@ -624,7 +628,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
position: "absolute",
left: 0,
right: 0,
bottom: 40,
bottom: scaleSize(40),
}}
>
<FlatList
@@ -633,12 +637,21 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
contentContainerStyle={{
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
// Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}

View File

@@ -1,7 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import React, { useEffect, useRef, useState } from "react";
import {
Animated,
BackHandler,
Easing,
Platform,
Pressable,
ScrollView,
View,
} from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -9,6 +17,7 @@ import { TVInput } from "./TVInput";
interface TVAddServerFormProps {
onConnect: (url: string) => Promise<void>;
onStartPairing?: () => void;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
@@ -78,6 +87,7 @@ const TVBackButton: React.FC<{
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
onConnect,
onStartPairing,
onBack,
loading = false,
disabled = false,
@@ -93,6 +103,24 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
const isDisabled = disabled || loading;
// Handle Android TV back button, needed as an "override"
useEffect(() => {
if (!Platform.isTV) return;
const handleBackPress = () => {
if (disabled) return false;
onBack();
return true;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [onBack, disabled]);
return (
<ScrollView
style={{ flex: 1 }}
@@ -156,6 +184,18 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Pair with Phone */}
{onStartPairing && (
<View style={{ marginTop: 32 }}>
<Button
onPress={onStartPairing}
className='bg-neutral-800 border border-neutral-700'
>
{t("pairing.pair_with_phone")}
</Button>
</View>
)}
</View>
</ScrollView>
);

View File

@@ -1,7 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import React, { useEffect, useRef, useState } from "react";
import {
Animated,
BackHandler,
Easing,
Platform,
Pressable,
ScrollView,
View,
} from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -108,6 +116,24 @@ export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
const isDisabled = disabled || loading;
// Handle Android TV back button, needed as an "override"
useEffect(() => {
if (!Platform.isTV) return;
const handleBackPress = () => {
if (disabled) return false;
onBack();
return true;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [onBack, disabled]);
return (
<ScrollView
style={{ flex: 1 }}

View File

@@ -2,28 +2,40 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
import { storage } from "@/utils/mmkv";
import {
generatePairingCode,
type PairingCredentials,
startPairingListener,
} from "@/utils/pairingService";
import {
type AccountSecurityType,
getPreviousServers,
hashPIN,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
saveAccountCredential,
} from "@/utils/secureCredentials";
import { TVAddServerForm } from "./TVAddServerForm";
import { TVAddUserForm } from "./TVAddUserForm";
import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
import { TVPINEntryModal } from "./TVPINEntryModal";
import { TVQRCodeDisplay } from "./TVQRCodeDisplay";
import { TVSaveAccountModal } from "./TVSaveAccountModal";
import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
type TVLoginScreen =
| "server-selection"
| "qr-code-display"
| "loading"
| "user-selection"
| "add-server"
| "add-user";
@@ -91,6 +103,17 @@ export const TVLogin: React.FC = () => {
const isAnyModalOpen =
showSaveModal || pinModalVisible || passwordModalVisible;
// Pairing state (companion login via phone)
const [showPairingQR, setShowPairingQR] = useState(false);
const [pairingCode, setPairingCode] = useState("");
const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{
serverUrl: string;
username: string;
password: string;
} | null>(null);
// Ref to prevent double-handling when onSave and onClose both fire
const pairingHandledRef = useRef(false);
// Refresh servers list helper
const refreshServers = () => {
const servers = getPreviousServers();
@@ -119,6 +142,7 @@ export const TVLogin: React.FC = () => {
useEffect(() => {
return () => {
stopQuickConnectPolling();
setShowPairingQR(false);
};
}, [stopQuickConnectPolling]);
@@ -262,6 +286,7 @@ export const TVLogin: React.FC = () => {
switch (account.securityType) {
case "none":
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(currentServer.address, account.userId);
@@ -281,7 +306,7 @@ export const TVLogin: React.FC = () => {
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("add-user"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
@@ -306,6 +331,7 @@ export const TVLogin: React.FC = () => {
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(
@@ -323,6 +349,12 @@ export const TVLogin: React.FC = () => {
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
@@ -334,6 +366,7 @@ export const TVLogin: React.FC = () => {
// Handle password submit
const handlePasswordSubmit = async (password: string) => {
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithPassword(
@@ -345,6 +378,12 @@ export const TVLogin: React.FC = () => {
Alert.alert(
t("login.connection_failed"),
t("login.invalid_username_or_password"),
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
@@ -408,7 +447,63 @@ export const TVLogin: React.FC = () => {
pinCode?: string,
) => {
setShowSaveModal(false);
const pairingCreds = pendingPairingCredentials;
if (pairingCreds) {
// Pairing flow: mark as handled, login, then save credential
pairingHandledRef.current = true;
setPendingPairingCredentials(null);
setPendingLogin(null);
setLoading(true);
try {
await loginWithPassword(
pairingCreds.serverUrl,
pairingCreds.username,
pairingCreds.password,
);
// Save credential after successful login
try {
const token = storage.getString("token");
const userJson = storage.getString("user");
const storedServerUrl = storage.getString("serverUrl");
if (token && userJson && storedServerUrl) {
const user = JSON.parse(userJson);
let pinHash: string | undefined;
if (securityType === "pin" && pinCode) {
pinHash = await hashPIN(pinCode);
}
await saveAccountCredential({
serverUrl: storedServerUrl,
serverName: storedServerUrl,
token,
userId: user.Id || "",
username: pairingCreds.username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
}
} catch (saveError) {
console.error(
"[TVLogin] Failed to save pairing credential:",
saveError,
);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
setCurrentScreen("qr-code-display");
} finally {
setLoading(false);
}
return;
}
// Normal login flow
if (pendingLogin && currentServer) {
setLoading(true);
try {
@@ -452,11 +547,72 @@ export const TVLogin: React.FC = () => {
}
};
// Handle pairing with companion phone
const handleStartPairing = useCallback(() => {
setCurrentScreen("qr-code-display");
const code = generatePairingCode();
setPairingCode(code);
setShowPairingQR(true);
}, []);
// Handle credentials received from companion
const handlePairingCredentials = useCallback(
(credentials: PairingCredentials) => {
setShowPairingQR(false);
setCurrentScreen("loading");
// Store credentials and show save modal (same UX as normal login)
setPendingPairingCredentials({
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
});
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
},
[],
);
// Listen for pairing credentials when QR is shown
useEffect(() => {
if (!showPairingQR || !pairingCode) return;
const cleanup = startPairingListener(
pairingCode,
handlePairingCredentials,
(error) => {
console.error("[TVLogin] Pairing error:", error);
setShowPairingQR(false);
Alert.alert(t("login.error_title"), t("companion_login.error_generic"));
},
);
// Auto-dismiss after 5 minutes
const timeout = setTimeout(
() => {
setShowPairingQR(false);
},
5 * 60 * 1000,
);
return () => {
cleanup();
clearTimeout(timeout);
};
}, [showPairingQR, pairingCode, handlePairingCredentials]);
// Render current screen
const renderScreen = () => {
// If API is connected but we're on server/user selection,
// it means we need to show add-user form
if (api?.basePath && currentScreen !== "add-user") {
if (
api?.basePath &&
currentScreen !== "add-user" &&
currentScreen !== "loading"
) {
// API is ready, show add-user form
return (
<TVAddUserForm
@@ -505,12 +661,56 @@ export const TVLogin: React.FC = () => {
return (
<TVAddServerForm
onConnect={handleConnect}
onStartPairing={handleStartPairing}
onBack={() => setCurrentScreen("server-selection")}
loading={loadingServerCheck}
disabled={isAnyModalOpen}
/>
);
case "qr-code-display":
return (
<TVQRCodeDisplay
code={pairingCode}
mode='pairing'
onBack={() => {
setShowPairingQR(false);
setCurrentScreen("add-server");
}}
/>
);
case "loading":
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
}}
>
{t("pairing.logging_in")}
</Text>
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
}}
>
{t("pairing.logging_in_description")}
</Text>
</View>
);
case "add-user":
return (
<TVAddUserForm
@@ -540,7 +740,31 @@ export const TVLogin: React.FC = () => {
<TVSaveAccountModal
visible={showSaveModal}
onClose={() => {
// If onSave already handled this, just clean up
if (pairingHandledRef.current) {
pairingHandledRef.current = false;
return;
}
setShowSaveModal(false);
if (pendingPairingCredentials) {
// Pairing: user dismissed without saving, login anyway
const creds = pendingPairingCredentials;
setPendingPairingCredentials(null);
setPendingLogin(null);
loginWithPassword(
creds.serverUrl,
creds.username,
creds.password,
).catch((error) => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
setCurrentScreen("qr-code-display");
});
return;
}
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}

View File

@@ -0,0 +1,203 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import React, { useEffect, useRef } from "react";
import {
Animated,
BackHandler,
Easing,
Platform,
Pressable,
View,
} from "react-native";
import QRCode from "react-native-qrcode-svg";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
interface TVQRCodeDisplayProps {
code: string;
mode: "pairing";
onBack?: () => void;
}
export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
code,
mode,
onBack,
}) => {
const typography = useScaledTVTypography();
const handledRef = useRef(false);
const qrSize = scaleSize(280);
const cardPadding = scaleSize(16);
const sectionPadding = scaleSize(32);
const outerPadding = scaleSize(60);
const qrData = JSON.stringify({
action: "streamyfin-pair",
code,
});
// Handle Android TV back button
useEffect(() => {
if (!Platform.isTV || !onBack) return;
const handleBackPress = () => {
if (handledRef.current) return true;
handledRef.current = true;
setTimeout(() => {
handledRef.current = false;
}, 100);
onBack();
return true;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [onBack]);
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: outerPadding,
}}
>
{/* Back Button */}
{onBack && <TVBackButton onPress={onBack} />}
{/* QR Code */}
<View
style={{
alignItems: "center",
paddingVertical: sectionPadding,
paddingHorizontal: cardPadding,
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.05)",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("pairing.waiting_for_phone")}
</Text>
<View
style={{
padding: cardPadding,
borderRadius: 12,
backgroundColor: "#FFFFFF",
}}
>
<QRCode
value={qrData}
size={qrSize}
color='#000000'
backgroundColor='#FFFFFF'
/>
</View>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
letterSpacing: scaleSize(8),
marginTop: scaleSize(16),
}}
>
{code}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: scaleSize(8),
}}
>
{t("pairing.scan_with_phone")}
</Text>
</View>
</View>
</View>
);
};
const TVBackButton: React.FC<{
onPress: () => void;
}> = ({ onPress }) => {
const [isFocused, setIsFocused] = React.useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 24 }}
focusable
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused ? "#fff" : "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#000" : "#fff"}
/>
<Text
style={{
color: isFocused ? "#000" : "#fff",
fontSize: 20,
marginLeft: 4,
}}
>
{t("common.back")}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -21,6 +21,7 @@ import {
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time";
export interface TVPosterCardProps {
@@ -225,7 +226,13 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
: null;
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
}}
>
{episodeLabel && (
<Text
style={{
@@ -259,7 +266,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ChannelName}
@@ -277,7 +284,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{artist}
@@ -296,7 +303,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{artist}
@@ -312,7 +319,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ChildCount} tracks
@@ -328,7 +335,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ProductionYear}
@@ -344,23 +351,23 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<View
style={{
position: "absolute",
top: 12,
left: 12,
top: scaleSize(12),
left: scaleSize(12),
backgroundColor: "#FFFFFF",
borderRadius: 8,
borderRadius: scaleSize(8),
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
paddingHorizontal: scaleSize(12),
paddingVertical: scaleSize(8),
gap: scaleSize(6),
zIndex: 10,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Ionicons name='play' size={scaleSize(16)} color='#000000' />
<Text
style={{
color: "#000000",
fontSize: 14,
fontSize: scaleSize(14),
fontWeight: "700",
}}
>
@@ -382,7 +389,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
<Ionicons name='play-circle' size={scaleSize(56)} color='white' />
</View>
) : null;
@@ -395,9 +402,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
width,
aspectRatio,
borderRadius: 24,
borderRadius: scaleSize(24),
backgroundColor: "#1a1a1a",
borderWidth: 2,
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
}}
/>
@@ -411,7 +418,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<GlassPosterView
imageUrl={imageUrl}
aspectRatio={aspectRatio}
cornerRadius={24}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={focused}
@@ -431,10 +438,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
position: "relative",
width,
aspectRatio,
borderRadius: 24,
borderRadius: scaleSize(4),
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: 2,
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
}}
>
@@ -470,7 +477,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
marginTop: scaleSize(4),
fontWeight: "500",
}}
>
@@ -498,8 +505,13 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
// Default: show name
return (
<Text
numberOfLines={1}
style={{ fontSize: typography.body, color: "#FFFFFF" }}
numberOfLines={3}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: scaleSize(4),
fontWeight: "500",
}}
>
{item.Name}
</Text>
@@ -551,7 +563,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
shadowColor: useGlass ? undefined : shadowColor,
shadowOffset: useGlass ? undefined : { width: 0, height: 0 },
shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0,
shadowRadius: useGlass ? undefined : focused ? 12 : 0,
shadowRadius: useGlass ? undefined : focused ? scaleSize(12) : 0,
}}
>
{renderPosterImage()}
@@ -560,7 +572,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
{/* Text below poster */}
{showText && (
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View
style={{ marginTop: scaleSize(12), paddingHorizontal: scaleSize(4) }}
>
{item.Type === "Episode" ? (
<>
{renderSubtitle()}

View File

@@ -1,10 +1,12 @@
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
/**
* TV Layout Sizes
*
* Unified constants for TV interface layout including posters, gaps, and padding.
* All values scale based on the user's tvTypographyScale setting.
* Base values are designed for 1920x1080 and scaled to the actual viewport via
* scaleSize(), then further adjusted by the user's tvTypographyScale setting.
*/
// =============================================================================
@@ -48,7 +50,7 @@ export const TVGaps = {
*/
export const TVPadding = {
/** Horizontal padding from screen edges */
horizontal: 60,
horizontal: 90,
/** Padding to accommodate scale animations (1.05x) */
scale: 20,
@@ -129,20 +131,20 @@ export const useScaledTVSizes = (): ScaledTVSizes => {
return {
posters: {
poster: Math.round(TVPosterSizes.poster * scale),
landscape: Math.round(TVPosterSizes.landscape * scale),
episode: Math.round(TVPosterSizes.episode * scale),
poster: Math.round(scaleSize(TVPosterSizes.poster) * scale),
landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale),
episode: Math.round(scaleSize(TVPosterSizes.episode) * scale),
},
gaps: {
item: Math.round(TVGaps.item * scale),
section: Math.round(TVGaps.section * scale),
small: Math.round(TVGaps.small * scale),
large: Math.round(TVGaps.large * scale),
item: Math.round(scaleSize(TVGaps.item) * scale),
section: Math.round(scaleSize(TVGaps.section) * scale),
small: Math.round(scaleSize(TVGaps.small) * scale),
large: Math.round(scaleSize(TVGaps.large) * scale),
},
padding: {
horizontal: Math.round(TVPadding.horizontal * scale),
scale: Math.round(TVPadding.scale * scale),
vertical: Math.round(TVPadding.vertical * scale),
horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale),
scale: Math.round(scaleSize(TVPadding.scale) * scale),
vertical: Math.round(scaleSize(TVPadding.vertical) * scale),
heroHeight: TVPadding.heroHeight * scale,
},
animation: TVAnimation,

View File

@@ -4,25 +4,28 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
* TV Typography Scale
*
* Consistent text sizes for TV interface components.
* These sizes are optimized for TV viewing distance.
* Design values are for 1920×1080 and scaled proportionally
* to the actual viewport via scaleSize().
*/
import { scaleSize } from "@/utils/scaleSize";
export const TVTypography = {
/** Hero titles, movie/show names - 70px */
display: 70,
/** Hero titles, movie/show names */
display: scaleSize(70),
/** Episode series name, major headings - 42px */
title: 42,
/** Episode series name, major headings */
title: scaleSize(42),
/** Section headers (Cast, Technical Details, From this Series) - 32px */
heading: 32,
/** Section headers (Cast, Technical Details, From this Series) */
heading: scaleSize(32),
/** Overview, actor names, card titles, metadata - 20px */
body: 20,
/** Overview, actor names, card titles, metadata */
body: scaleSize(40),
/** Secondary text, labels, subtitles - 16px */
callout: 16,
} as const;
/** Secondary text, labels, subtitles */
callout: scaleSize(26),
};
export type TVTypographyKey = keyof typeof TVTypography;

View File

@@ -1,6 +1,8 @@
package expo.modules.mpvplayer
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.content.res.AssetManager
import android.os.Handler
import android.os.Looper
@@ -27,7 +29,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
const val MPV_FORMAT_DOUBLE = 5
const val MPV_FORMAT_NODE = 6
}
private fun isTvDevice(): Boolean {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
interface Delegate {
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean)
@@ -157,7 +164,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("opengl-es", "yes")
// Hardware video decoding
MPVLib.setOptionString("hwdec", "mediacodec-copy")
// TV: zero-copy (mediacodec) for better performance on low-power devices
// Mobile: copy mode (mediacodec-copy) for better compatibility
val isTV = isTvDevice()
if (isTV) {
MPVLib.setOptionString("hwdec", "mediacodec")
MPVLib.setOptionString("profile", "fast")
} else {
MPVLib.setOptionString("hwdec", "mediacodec-copy")
}
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
// Cache settings for better network streaming

View File

@@ -50,6 +50,7 @@
"expo-blur": "~15.0.8",
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "^55.0.18",
"expo-constants": "18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
@@ -97,6 +98,7 @@
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.6.0",

View File

@@ -979,5 +979,35 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan a QR code from your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

142
utils/pairingService.ts Normal file
View File

@@ -0,0 +1,142 @@
import dgram from "react-native-udp";
const PAIRING_PORT = 54322;
const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response";
export interface PairingCredentials {
serverUrl: string;
username: string;
password: string;
}
export function generatePairingCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
export function startPairingListener(
code: string,
onCredentialsReceived: (credentials: PairingCredentials) => void,
onError?: (error: string) => void,
): () => void {
let active = true;
const socket = dgram.createSocket({
type: "udp4",
reusePort: true,
debug: __DEV__,
});
socket.on("error", (err) => {
console.error("[PairingService] Socket error:", err);
onError?.(err.message);
cleanup();
});
socket.bind(PAIRING_PORT, () => {
console.log("[PairingService] Listening on port", PAIRING_PORT);
});
socket.on("message", (msg) => {
if (!active) return;
try {
const data = JSON.parse(new TextDecoder().decode(msg));
if (data.type !== PAIRING_MESSAGE_TYPE) return;
if (data.code !== code) return;
if (!data.server_url || !data.username || !data.password) {
console.error("[PairingService] Missing fields in pairing response");
return;
}
console.log("[PairingService] Credentials received");
active = false;
onCredentialsReceived({
serverUrl: data.server_url,
username: data.username,
password: data.password,
});
cleanup();
} catch (error) {
console.error("[PairingService] Error parsing message:", error);
}
});
function cleanup() {
active = false;
try {
socket.close();
} catch {
// Socket may already be closed
}
}
return cleanup;
}
export function sendCredentialsToTV(
code: string,
serverUrl: string,
username: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({
type: "udp4",
reusePort: true,
debug: __DEV__,
});
const message = JSON.stringify({
type: PAIRING_MESSAGE_TYPE,
code,
server_url: serverUrl,
username,
password,
});
const messageBuffer = new TextEncoder().encode(message);
socket.on("error", (err) => {
reject(err);
try {
socket.close();
} catch {
// Ignore
}
});
socket.bind(0, () => {
try {
socket.setBroadcast(true);
socket.send(
messageBuffer,
0,
messageBuffer.length,
PAIRING_PORT,
"255.255.255.255",
(err) => {
try {
socket.close();
} catch {
// Ignore
}
if (err) {
reject(err);
} else {
resolve();
}
},
);
} catch (error) {
try {
socket.close();
} catch {
// Ignore
}
reject(error);
}
});
});
}

9
utils/scaleSize.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Dimensions } from "react-native";
const { width: W, height: H } = Dimensions.get("window");
export const scaleSize = (size: number): number => {
const widthRatio = W / 1920;
const heightRatio = H / 1080;
return size * Math.min(widthRatio, heightRatio);
};