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