From 65f94931c59f081f663d0caefb316a2d15f3f27d Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 06:22:29 +0300 Subject: [PATCH] feat: add TV Quick Connect to connection page for Android TV / Apple TV When user selects Android TV or Apple TV platform (from non-TV device), shows 5-char code input + QR scanner to send subscription to TV via Happ TV API. Hybrid QR strategy: native Telegram scanner on mobile, html5-qrcode CDN fallback on desktop. i18n keys for ru/en/zh/fa. Based on PR #415 by @smediainfo. --- .../connection/InstallationGuide.tsx | 37 +- src/components/connection/TvQuickConnect.tsx | 354 ++++++++++++++++++ src/locales/en.json | 14 + src/locales/fa.json | 14 + src/locales/ru.json | 14 + src/locales/zh.json | 14 + 6 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 src/components/connection/TvQuickConnect.tsx diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index f825bd4..0b6b7cc 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -11,6 +11,7 @@ import type { import { useTheme } from '@/hooks/useTheme'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import type { BlockRendererProps } from './blocks'; +import TvQuickConnect from './TvQuickConnect'; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; @@ -145,6 +146,12 @@ export default function InstallationGuide({ ], ); + const selectedIsTv = + (activePlatformKey || availablePlatforms[0]) === 'androidTV' || + (activePlatformKey || availablePlatforms[0]) === 'appleTV'; + const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV'; + const isTvPlatform = selectedIsTv && !userIsOnTv; + const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformData = currentPlatformKey ? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined) @@ -328,8 +335,32 @@ export default function InstallationGuide({ )} - {/* Blocks */} - {selectedApp && ( + {/* Blocks — for TV: first block, Quick Connect, last block */} + {selectedApp && isTvPlatform && appConfig.subscriptionUrl ? ( + <> + {selectedApp.blocks.length > 0 && ( + + )} + + {selectedApp.blocks.length > 1 && ( + + )} + + ) : selectedApp ? ( - )} + ) : null} ); } diff --git a/src/components/connection/TvQuickConnect.tsx b/src/components/connection/TvQuickConnect.tsx new file mode 100644 index 0000000..567a00e --- /dev/null +++ b/src/components/connection/TvQuickConnect.tsx @@ -0,0 +1,354 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + openQrScanner, + isQrScannerSupported, + retrieveLaunchParams, +} from '@telegram-apps/sdk-react'; + +const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); + +function isTelegramMobile(): boolean { + try { + const platform = retrieveLaunchParams().tgWebAppPlatform; + return TG_MOBILE_PLATFORMS.has(platform); + } catch { + return false; + } +} + +const HAPP_TV_API = 'https://check.happ.su/sendtv'; +const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js'; + +interface Props { + subscriptionUrl: string; + isLight: boolean; +} + +interface Html5QrcodeInstance { + start: ( + cameraIdOrConfig: { facingMode: string } | string, + config: { fps: number; qrbox: { width: number; height: number } }, + onSuccess: (decoded: string) => void, + onError: () => void, + ) => Promise; + stop: () => Promise; + clear: () => void; +} + +export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { + const { t } = useTranslation(); + const [code, setCode] = useState(''); + const [sending, setSending] = useState(false); + const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + const [scanning, setScanning] = useState(false); + const [tgNative, setTgNative] = useState(false); + const toastTimerRef = useRef>(undefined); + const scannerRef = useRef(null); + + useEffect(() => { + try { + setTgNative(isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable()); + } catch { + setTgNative(false); + } + return () => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + if (scannerRef.current) { + scannerRef.current + .stop() + .catch(() => undefined) + .finally(() => { + scannerRef.current?.clear(); + scannerRef.current = null; + }); + } + }; + }, []); + + const showToast = useCallback((text: string, type: 'success' | 'error') => { + setToast({ text, type }); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + toastTimerRef.current = setTimeout(() => setToast(null), 3000); + }, []); + + const sendToTV = useCallback( + async (tvCode: string) => { + if (sending) return; + const clean = tvCode.trim().toUpperCase(); + if (!(clean.length === 5 && /^[A-Z0-9]+$/.test(clean))) { + showToast(t('subscription.tvQuickConnect.badCode'), 'error'); + return; + } + + setSending(true); + try { + const b64 = btoa(unescape(encodeURIComponent(subscriptionUrl))); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 10000); + + const res = await fetch(`${HAPP_TV_API}/${encodeURIComponent(clean)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: b64 }), + signal: ctrl.signal, + }); + clearTimeout(timer); + + if (res.ok) { + showToast(t('subscription.tvQuickConnect.sent'), 'success'); + setCode(''); + } else { + showToast(t('subscription.tvQuickConnect.error'), 'error'); + } + } catch { + showToast(t('subscription.tvQuickConnect.error'), 'error'); + } finally { + setSending(false); + } + }, + [sending, subscriptionUrl, showToast, t], + ); + + const stopScan = useCallback(() => { + if (scannerRef.current) { + scannerRef.current + .stop() + .catch(() => undefined) + .finally(() => { + scannerRef.current?.clear(); + scannerRef.current = null; + }); + } + setScanning(false); + }, []); + + const onScanDecoded = useCallback( + (decoded: string) => { + const parsed = parseQRCode(decoded); + if (!parsed) return; + stopScan(); + setCode(parsed); + showToast(`${t('subscription.tvQuickConnect.codeFound')}: ${parsed}`, 'success'); + setTimeout(() => sendToTV(parsed), 500); + }, + [stopScan, showToast, sendToTV, t], + ); + + const startScan = useCallback(async () => { + // Telegram Mini App: native scanner + if (tgNative) { + try { + const qr = await openQrScanner({ + text: t('subscription.tvQuickConnect.scanDescription'), + capture: (s: string) => parseQRCode(s) !== null, + }); + if (qr) { + const parsed = parseQRCode(qr); + if (parsed) { + setCode(parsed); + showToast(t('subscription.tvQuickConnect.codeFound'), 'success'); + sendToTV(parsed); + } + } + } catch { + showToast(t('subscription.tvQuickConnect.error'), 'error'); + } + return; + } + + // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode + type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; + const w = window as WindowWithHtml5; + if (!w.Html5Qrcode) { + const script = document.createElement('script'); + script.src = HTML5_QRCODE_CDN; + document.head.appendChild(script); + await new Promise((resolve, reject) => { + script.onload = () => resolve(); + script.onerror = () => reject(); + }).catch(() => undefined); + } + if (!w.Html5Qrcode) { + showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); + return; + } + setScanning(true); + const scanner = new w.Html5Qrcode('tv-qr-reader'); + scannerRef.current = scanner; + const config = { fps: 10, qrbox: { width: 220, height: 220 } }; + try { + await scanner.start({ facingMode: 'environment' }, config, onScanDecoded, () => undefined); + } catch { + try { + await scanner.start({ facingMode: 'user' }, config, onScanDecoded, () => undefined); + } catch { + showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); + scannerRef.current = null; + setScanning(false); + } + } + }, [tgNative, sendToTV, showToast, onScanDecoded, t]); + + const cardClass = isLight + ? 'rounded-2xl border border-dark-700/60 bg-white/80 shadow-sm p-4 sm:p-5' + : 'rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5'; + + const inputClass = isLight + ? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500' + : 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'; + + return ( +
+ {/* Code input */} +
+
+
+ + + +
+
+

+ {t('subscription.tvQuickConnect.title')} +

+

+ {t('subscription.tvQuickConnect.description')} +

+ +
+ setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} + placeholder="A1B2C" + autoComplete="one-time-code" + inputMode="text" + className={inputClass} + /> + +
+
+
+
+ + {/* QR Scanner */} +
+
+
+ + + + +
+
+

+ {t('subscription.tvQuickConnect.scanTitle')} +

+

+ {t('subscription.tvQuickConnect.scanDescription')} +

+ + {!scanning && ( + + )} +
+
+ {scanning && ( + + )} +
+
+
+
+ + {/* Toast */} + {toast && ( +
+ {toast.text} +
+ )} +
+ ); +} + +function parseQRCode(data: string): string | null { + if (data.length === 5 && /^[A-Z0-9]+$/i.test(data)) { + return data.toUpperCase(); + } + try { + const url = new URL(data); + const parts = url.pathname.split('/').filter((p) => p.length > 0); + if (parts.length > 0) { + const last = parts[parts.length - 1]; + if (last.length === 5 && /^[A-Z0-9]+$/i.test(last)) return last.toUpperCase(); + } + const codeParam = url.searchParams.get('code'); + if (codeParam?.length === 5 && /^[A-Z0-9]+$/i.test(codeParam)) return codeParam.toUpperCase(); + } catch { + const m = data.match(/[/=]([A-Z0-9]{5})(?:[/?&\s]|$)/i); + if (m?.[1]) return m[1].toUpperCase(); + } + return null; +} diff --git a/src/locales/en.json b/src/locales/en.json index b03b81e..bc7054e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -593,6 +593,20 @@ "label": "Traffic", "selectVolume": "Select traffic volume", "default": "Default: {{label}}" + }, + "tvQuickConnect": { + "title": "Connect TV", + "description": "Enter the 5-character code from your TV screen", + "sendBtn": "Send to TV", + "badCode": "Enter a 5-character code (letters and digits)", + "sent": "Sent to TV!", + "error": "Failed to send to TV", + "scanTitle": "Scan QR Code", + "scanDescription": "Scan the QR code from your TV screen", + "scanBtn": "Scan QR from TV", + "stopScan": "Close camera", + "noCamera": "Camera access denied", + "codeFound": "Code found" } }, "balance": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 6d0752d..026178c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -472,6 +472,20 @@ "resumeBtn": "ادامه", "title": "توقف اشتراک", "willBeCharged": "کسر خواهد شد" + }, + "tvQuickConnect": { + "title": "اتصال تلویزیون", + "description": "کد ۵ رقمی نمایش داده شده روی صفحه تلویزیون را وارد کنید", + "sendBtn": "ارسال به تلویزیون", + "badCode": "کد ۵ رقمی وارد کنید (حروف و اعداد)", + "sent": "به تلویزیون ارسال شد!", + "error": "خطا در ارسال به تلویزیون", + "scanTitle": "اسکن کد QR", + "scanDescription": "کد QR روی صفحه تلویزیون را اسکن کنید", + "scanBtn": "اسکن QR از تلویزیون", + "stopScan": "بستن دوربین", + "noCamera": "دسترسی به دوربین امکان‌پذیر نیست", + "codeFound": "کد پیدا شد" } }, "balance": { diff --git a/src/locales/ru.json b/src/locales/ru.json index ef2b06c..8d0fb1b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -621,6 +621,20 @@ "label": "Трафик", "selectVolume": "Выбрать объём трафика", "default": "По умолчанию: {{label}}" + }, + "tvQuickConnect": { + "title": "Подключить TV", + "description": "Введите 5-значный код с экрана телевизора", + "sendBtn": "Отправить на TV", + "badCode": "Введите 5-значный код (буквы и цифры)", + "sent": "Отправлено на TV!", + "error": "Ошибка отправки на TV", + "scanTitle": "Сканировать QR-код", + "scanDescription": "Отсканируйте QR-код с экрана телевизора", + "scanBtn": "Сканировать QR с TV", + "stopScan": "Закрыть камеру", + "noCamera": "Нет доступа к камере", + "codeFound": "Код найден" } }, "balance": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 1571e62..919a31a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -472,6 +472,20 @@ "resumeBtn": "恢复", "title": "暂停订阅", "willBeCharged": "将扣费" + }, + "tvQuickConnect": { + "title": "连接电视", + "description": "输入电视屏幕上显示的5位代码", + "sendBtn": "发送到电视", + "badCode": "请输入5位代码(字母和数字)", + "sent": "已发送到电视!", + "error": "发送到电视失败", + "scanTitle": "扫描二维码", + "scanDescription": "扫描电视屏幕上的二维码", + "scanBtn": "扫描电视二维码", + "stopScan": "关闭摄像头", + "noCamera": "无法访问摄像头", + "codeFound": "找到代码" } }, "balance": {