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>
This commit is contained in:
Lance Chant
2026-05-15 14:08:32 +02:00
parent c012bd44bd
commit 1f324f8d46
13 changed files with 1273 additions and 8 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

@@ -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

@@ -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

@@ -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

@@ -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);
}
});
});
}