diff --git a/app.config.js b/app.config.js index 2e37927b..96bbd8ea 100644 --- a/app.config.js +++ b/app.config.js @@ -6,6 +6,14 @@ module.exports = ({ config }) => { "react-native-google-cast", { useDefaultExpandedMediaControls: true }, ]); + + config.plugins.push([ + "expo-camera", + { + cameraPermission: + "Allow Streamyfin to access the camera to scan QR codes for TV login.", + }, + ]); } // Only override googleServicesFile if env var is set diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index ec8b3517..591759b9 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -96,6 +96,25 @@ export default function IndexLayout() { ), }} /> + ( + _router.back()} + className='pl-0.5' + style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} + > + + + ), + }} + /> ; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 1ed36fe2..4c38659e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -59,6 +59,18 @@ function SettingsMobile() { + router.push("/(auth)/(tabs)/(home)/companion-login")} + > + + {t("pairing.pair_with_phone_title")} + + + {t("pairing.pair_with_phone_description")} + + + diff --git a/bun.lock b/bun.lock index 7abad3e1..ab9acf3e 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "^55.0.18", "expo-constants": "18.0.13", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -77,6 +78,7 @@ "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", "react-native-safe-area-context": "~5.6.0", @@ -621,6 +623,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], + "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], @@ -761,6 +765,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "barcode-detector": ["barcode-detector@3.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="], @@ -929,6 +935,8 @@ "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -1019,6 +1027,8 @@ "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], + "expo-camera": ["expo-camera@55.0.18", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA=="], + "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="], @@ -1573,7 +1583,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1617,6 +1627,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], @@ -1685,6 +1697,8 @@ "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], + "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="], + "react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="], @@ -1889,6 +1903,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], @@ -1901,6 +1917,8 @@ "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + "text-encoding": ["text-encoding@0.7.0", "", {}, "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -2045,6 +2063,8 @@ "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], @@ -2409,12 +2429,16 @@ "parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -2423,6 +2447,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -2489,6 +2515,8 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], @@ -2981,6 +3009,14 @@ "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3165,6 +3201,12 @@ "metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3201,6 +3243,10 @@ "logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], @@ -3213,6 +3259,12 @@ "logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], } } diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx new file mode 100644 index 00000000..e256dc12 --- /dev/null +++ b/components/companion/CompanionLoginScreen.tsx @@ -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("scanning"); + const [_hasPermission, setHasPermission] = useState(null); + const [pairingCode, setPairingCode] = useState(""); + const [serverUrl, setServerUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + // Pre-fill server URL and username from current session + useEffect(() => { + if (api?.basePath) { + setServerUrl(api.basePath); + } + + if (user?.Name) { + setUsername(user.Name); + } + }, [api?.basePath, user?.Name]); + + // Request camera permission + useEffect(() => { + Camera.getCameraPermissionsAsync().then((response) => { + 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 ( + + + + {t("companion_login.error_permission_denied")} + + + {Platform.OS === "ios" && ( + Linking.openSettings()} + className='mt-4 rounded-lg bg-purple-600 px-6 py-3' + > + + Open Settings + + + )} + + + + + ); + } + + if (screenState === "success") { + return ( + + + + {t("companion_login.success_title")} + + + + {t("companion_login.pairing_tv_connecting")} + + + + + + ); + } + + if (screenState === "error") { + return ( + + + + {t("companion_login.error_title")} + + + + {errorMessage} + + + + + + + + + + ); + } + + if (screenState === "sending") { + return ( + + + + {t("companion_login.authorizing")} + + + + ); + } + + if (screenState === "confirm") { + return ( + + + + {t("companion_login.login_as", { username })} + + + + {t("companion_login.on_server", { + server: serverUrl.replace(/^https?:\/\//, ""), + })} + + + + + {t("companion_login.pairing_code_label")} + + + + {pairingCode} + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + {t("companion_login.use_different_user")} + + + + + + {t("companion_login.scan_again")} + + + + + + ); + } + + if (screenState === "form") { + return ( + + + + {t("companion_login.pairing_enter_credentials")} + + + + {t("companion_login.pairing_code_label")} + + + + {pairingCode} + + + + + {t("companion_login.server")} + + + + + + + + {t("login.username_placeholder")} + + + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + ); + } + + return ( + + {/* Camera full screen */} + + + {/* Dark overlay */} + + + {/* Center scan area */} + + + + + {t("companion_login.align_qr")} + + + + + {t("companion_login.enter_code_manually")} + + + + + ); +}; diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx index 7d3cefe1..ab74c55a 100644 --- a/components/login/TVAddServerForm.tsx +++ b/components/login/TVAddServerForm.tsx @@ -1,7 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useRef, useState } from "react"; -import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + BackHandler, + Easing, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -9,6 +17,7 @@ import { TVInput } from "./TVInput"; interface TVAddServerFormProps { onConnect: (url: string) => Promise; + onStartPairing?: () => void; onBack: () => void; loading?: boolean; disabled?: boolean; @@ -78,6 +87,7 @@ const TVBackButton: React.FC<{ export const TVAddServerForm: React.FC = ({ onConnect, + onStartPairing, onBack, loading = false, disabled = false, @@ -93,6 +103,24 @@ export const TVAddServerForm: React.FC = ({ const isDisabled = disabled || loading; + // Handle Android TV back button, needed as an "override" + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (disabled) return false; + onBack(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onBack, disabled]); + return ( = ({ > {t("server.enter_url_to_jellyfin_server")} + + {/* Pair with Phone */} + {onStartPairing && ( + + + + )} ); diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx index 17f8fe89..d75bc085 100644 --- a/components/login/TVAddUserForm.tsx +++ b/components/login/TVAddUserForm.tsx @@ -1,7 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { t } from "i18next"; -import React, { useRef, useState } from "react"; -import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + BackHandler, + Easing, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -108,6 +116,24 @@ export const TVAddUserForm: React.FC = ({ const isDisabled = disabled || loading; + // Handle Android TV back button, needed as an "override" + useEffect(() => { + if (!Platform.isTV) return; + + const handleBackPress = () => { + if (disabled) return false; + onBack(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [onBack, disabled]); + return ( { const isAnyModalOpen = showSaveModal || pinModalVisible || passwordModalVisible; + // Pairing state (companion login via phone) + const [showPairingQR, setShowPairingQR] = useState(false); + const [pairingCode, setPairingCode] = useState(""); + const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{ + serverUrl: string; + username: string; + password: string; + } | null>(null); + // Ref to prevent double-handling when onSave and onClose both fire + const pairingHandledRef = useRef(false); + // Refresh servers list helper const refreshServers = () => { const servers = getPreviousServers(); @@ -119,6 +142,7 @@ export const TVLogin: React.FC = () => { useEffect(() => { return () => { stopQuickConnectPolling(); + setShowPairingQR(false); }; }, [stopQuickConnectPolling]); @@ -262,6 +286,7 @@ export const TVLogin: React.FC = () => { switch (account.securityType) { case "none": + setCurrentScreen("loading"); setLoading(true); try { await loginWithSavedCredential(currentServer.address, account.userId); @@ -281,7 +306,7 @@ export const TVLogin: React.FC = () => { [ { text: t("common.ok"), - onPress: () => setCurrentScreen("add-user"), + onPress: () => setCurrentScreen("user-selection"), }, ], ); @@ -306,6 +331,7 @@ export const TVLogin: React.FC = () => { const handlePinSuccess = async () => { setPinModalVisible(false); if (currentServer && selectedAccount) { + setCurrentScreen("loading"); setLoading(true); try { await loginWithSavedCredential( @@ -323,6 +349,12 @@ export const TVLogin: React.FC = () => { ? t("server.session_expired") : t("login.connection_failed"), isSessionExpired ? t("server.please_login_again") : errorMessage, + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], ); } finally { setLoading(false); @@ -334,6 +366,7 @@ export const TVLogin: React.FC = () => { // Handle password submit const handlePasswordSubmit = async (password: string) => { if (currentServer && selectedAccount) { + setCurrentScreen("loading"); setLoading(true); try { await loginWithPassword( @@ -345,6 +378,12 @@ export const TVLogin: React.FC = () => { Alert.alert( t("login.connection_failed"), t("login.invalid_username_or_password"), + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], ); } finally { setLoading(false); @@ -408,7 +447,63 @@ export const TVLogin: React.FC = () => { pinCode?: string, ) => { setShowSaveModal(false); + const pairingCreds = pendingPairingCredentials; + if (pairingCreds) { + // Pairing flow: mark as handled, login, then save credential + pairingHandledRef.current = true; + setPendingPairingCredentials(null); + setPendingLogin(null); + setLoading(true); + try { + await loginWithPassword( + pairingCreds.serverUrl, + pairingCreds.username, + pairingCreds.password, + ); + // Save credential after successful login + try { + const token = storage.getString("token"); + const userJson = storage.getString("user"); + const storedServerUrl = storage.getString("serverUrl"); + if (token && userJson && storedServerUrl) { + const user = JSON.parse(userJson); + let pinHash: string | undefined; + if (securityType === "pin" && pinCode) { + pinHash = await hashPIN(pinCode); + } + await saveAccountCredential({ + serverUrl: storedServerUrl, + serverName: storedServerUrl, + token, + userId: user.Id || "", + username: pairingCreds.username, + savedAt: Date.now(), + securityType, + pinHash, + primaryImageTag: user.PrimaryImageTag ?? undefined, + }); + } + } catch (saveError) { + console.error( + "[TVLogin] Failed to save pairing credential:", + saveError, + ); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occured"); + Alert.alert(t("login.connection_failed"), message); + 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 ( { return ( setCurrentScreen("server-selection")} loading={loadingServerCheck} disabled={isAnyModalOpen} /> ); + case "qr-code-display": + return ( + { + setShowPairingQR(false); + setCurrentScreen("add-server"); + }} + /> + ); + + case "loading": + return ( + + + {t("pairing.logging_in")} + + + {t("pairing.logging_in_description")} + + + ); + case "add-user": return ( { { + // If onSave already handled this, just clean up + if (pairingHandledRef.current) { + pairingHandledRef.current = false; + return; + } setShowSaveModal(false); + if (pendingPairingCredentials) { + // Pairing: user dismissed without saving, login anyway + const creds = pendingPairingCredentials; + setPendingPairingCredentials(null); + setPendingLogin(null); + loginWithPassword( + creds.serverUrl, + creds.username, + creds.password, + ).catch((error) => { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occured"); + Alert.alert(t("login.connection_failed"), message); + setCurrentScreen("qr-code-display"); + }); + return; + } setPendingLogin(null); }} onSave={handleSaveAccountConfirm} diff --git a/components/login/TVQRCodeDisplay.tsx b/components/login/TVQRCodeDisplay.tsx new file mode 100644 index 00000000..a6521797 --- /dev/null +++ b/components/login/TVQRCodeDisplay.tsx @@ -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 = ({ + 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 ( + + + {/* Back Button */} + {onBack && } + + {/* QR Code */} + + + {t("pairing.waiting_for_phone")} + + + + + + + + {code} + + + + {t("pairing.scan_with_phone")} + + + + + ); +}; + +const TVBackButton: React.FC<{ + onPress: () => void; +}> = ({ onPress }) => { + const [isFocused, setIsFocused] = React.useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + return ( + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + style={{ alignSelf: "flex-start", marginBottom: 24 }} + focusable + > + + + + {t("common.back")} + + + + ); +}; diff --git a/package.json b/package.json index f231e2bf..b5f40efb 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", + "expo-camera": "^55.0.18", "expo-constants": "18.0.13", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -97,6 +98,7 @@ "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", "react-native-safe-area-context": "~5.6.0", diff --git a/translations/en.json b/translations/en.json index e5352295..a137b2e1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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" } } diff --git a/utils/pairingService.ts b/utils/pairingService.ts new file mode 100644 index 00000000..75566119 --- /dev/null +++ b/utils/pairingService.ts @@ -0,0 +1,142 @@ +import dgram from "react-native-udp"; + +const PAIRING_PORT = 54322; +const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response"; + +export interface PairingCredentials { + serverUrl: string; + username: string; + password: string; +} + +export function generatePairingCode(): string { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +export function startPairingListener( + code: string, + onCredentialsReceived: (credentials: PairingCredentials) => void, + onError?: (error: string) => void, +): () => void { + let active = true; + + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + socket.on("error", (err) => { + console.error("[PairingService] Socket error:", err); + onError?.(err.message); + cleanup(); + }); + + socket.bind(PAIRING_PORT, () => { + console.log("[PairingService] Listening on port", PAIRING_PORT); + }); + + socket.on("message", (msg) => { + if (!active) return; + + try { + const data = JSON.parse(new TextDecoder().decode(msg)); + + if (data.type !== PAIRING_MESSAGE_TYPE) return; + if (data.code !== code) return; + + if (!data.server_url || !data.username || !data.password) { + console.error("[PairingService] Missing fields in pairing response"); + return; + } + + console.log("[PairingService] Credentials received"); + active = false; + onCredentialsReceived({ + serverUrl: data.server_url, + username: data.username, + password: data.password, + }); + cleanup(); + } catch (error) { + console.error("[PairingService] Error parsing message:", error); + } + }); + + function cleanup() { + active = false; + try { + socket.close(); + } catch { + // Socket may already be closed + } + } + + return cleanup; +} + +export function sendCredentialsToTV( + code: string, + serverUrl: string, + username: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + const message = JSON.stringify({ + type: PAIRING_MESSAGE_TYPE, + code, + server_url: serverUrl, + username, + password, + }); + + const messageBuffer = new TextEncoder().encode(message); + + socket.on("error", (err) => { + reject(err); + try { + socket.close(); + } catch { + // Ignore + } + }); + + socket.bind(0, () => { + try { + socket.setBroadcast(true); + socket.send( + messageBuffer, + 0, + messageBuffer.length, + PAIRING_PORT, + "255.255.255.255", + (err) => { + try { + socket.close(); + } catch { + // Ignore + } + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + } catch (error) { + try { + socket.close(); + } catch { + // Ignore + } + reject(error); + } + }); + }); +}