import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; void main() { runApp(const InstaTvApp()); } enum EditTarget { username, password } enum TvAction { scrollUp, scrollDown, playSound, fullscreen, exitFullscreen } class TvControl { final String label; final TvAction action; const TvControl(this.label, this.action); } class InstaTvApp extends StatefulWidget { const InstaTvApp({super.key}); @override State createState() => _InstaTvAppState(); } class _InstaTvAppState extends State { late final WebViewController controller; final FocusNode mainFocusNode = FocusNode(); final FocusNode inputFocusNode = FocusNode(); final TextEditingController inputController = TextEditingController(); double cursorX = 280; double cursorY = 180; String usernameValue = ''; String passwordValue = ''; String lastAction = 'prêt'; EditTarget? editTarget; bool videoFullscreen = false; static const double step = 35; static const double cursorSize = 18; static const double controlsLeft = 16; static const double controlsTop = 72; static const double controlWidth = 260; static const double controlHeight = 58; static const double controlGap = 10; final List controls = const [ TvControl('Scroll haut', TvAction.scrollUp), TvControl('Scroll bas', TvAction.scrollDown), TvControl('Play / Son', TvAction.playSound), TvControl('Fullscreen', TvAction.fullscreen), TvControl('Sortir full', TvAction.exitFullscreen), ]; @override void initState() { super.initState(); final c = WebViewController(); if (c.platform is AndroidWebViewController) { AndroidWebViewController.enableDebugging(true); (c.platform as AndroidWebViewController) .setMediaPlaybackRequiresUserGesture(false); } c ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( onPageFinished: (_) => injectPageHelpers(), onNavigationRequest: (request) { final url = request.url; if (!url.startsWith('http://') && !url.startsWith('https://')) { return NavigationDecision.prevent; } return NavigationDecision.navigate; }, ), ) ..loadRequest( Uri.parse('https://www.instagram.com/accounts/login/?hl=fr'), ); controller = c; WidgetsBinding.instance.addPostFrameCallback((_) { mainFocusNode.requestFocus(); }); } @override void dispose() { mainFocusNode.dispose(); inputFocusNode.dispose(); inputController.dispose(); super.dispose(); } Future injectPageHelpers() async { const js = ''' (() => { if (window.__instaTvHelpersInstalled) return; window.__instaTvHelpersInstalled = true; const style = document.createElement('style'); style.textContent = ` video.__instaTvFullscreenVideo { position: fixed !important; inset: 0 !important; width: 100vw !important; height: 100vh !important; max-width: 100vw !important; max-height: 100vh !important; object-fit: contain !important; background: black !important; z-index: 2147483000 !important; } body.__instaTvNoScroll { overflow: hidden !important; } `; document.head.appendChild(style); window.__instaTvGetBestVideo = () => { const fullscreen = document.querySelector('video.__instaTvFullscreenVideo'); if (fullscreen) return fullscreen; const videos = Array.from(document.querySelectorAll('video')); let best = null; let bestArea = 0; for (const v of videos) { const r = v.getBoundingClientRect(); const visible = r.width > 80 && r.height > 80 && r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth; if (!visible) continue; const area = r.width * r.height; if (area > bestArea) { best = v; bestArea = area; } } return best; }; })(); '''; await controller.runJavaScript(js); } bool isOkKey(KeyEvent event) { final logical = event.logicalKey; final logicalName = logical.debugName?.toLowerCase() ?? ''; final physicalName = event.physicalKey.debugName?.toLowerCase() ?? ''; return logical == LogicalKeyboardKey.select || logical == LogicalKeyboardKey.enter || logical == LogicalKeyboardKey.numpadEnter || logical == LogicalKeyboardKey.space || logicalName.contains('select') || logicalName.contains('enter') || logicalName.contains('center') || logicalName.contains('accept') || logicalName.contains('button a') || physicalName.contains('select') || physicalName.contains('enter') || physicalName.contains('center') || physicalName.contains('accept') || physicalName.contains('button a'); } KeyEventResult handleMainKey(KeyEvent event, BoxConstraints box) { if (event is! KeyDownEvent) return KeyEventResult.handled; final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowLeft) { setState(() { cursorX = (cursorX - step).clamp(0, box.maxWidth - cursorSize); lastAction = 'gauche'; }); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight) { setState(() { cursorX = (cursorX + step).clamp(0, box.maxWidth - cursorSize); lastAction = 'droite'; }); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowUp) { setState(() { cursorY = (cursorY - step).clamp(0, box.maxHeight - cursorSize); lastAction = 'haut'; }); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown) { setState(() { cursorY = (cursorY + step).clamp(0, box.maxHeight - cursorSize); lastAction = 'bas'; }); return KeyEventResult.handled; } if (isOkKey(event)) { handleOk(box); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.goBack || key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.browserBack) { goBack(); return KeyEventResult.handled; } return KeyEventResult.handled; } TvAction? hitTestControl() { final centerX = cursorX + cursorSize / 2; final centerY = cursorY + cursorSize / 2; for (int i = 0; i < controls.length; i++) { final top = controlsTop + i * (controlHeight + controlGap); final bottom = top + controlHeight; if (centerX >= controlsLeft && centerX <= controlsLeft + controlWidth && centerY >= top && centerY <= bottom) { return controls[i].action; } } return null; } Future handleOk(BoxConstraints box) async { final action = hitTestControl(); if (action != null) { await runControlAction(action); return; } final loginVisible = await isLoginFormVisible(); final yRatio = cursorY / box.maxHeight; if (loginVisible && cursorX > 300 && yRatio >= 0.30 && yRatio < 0.46) { openOverlayInput(EditTarget.username); return; } if (loginVisible && cursorX > 300 && yRatio >= 0.46 && yRatio < 0.57) { openOverlayInput(EditTarget.password); return; } setState(() { lastAction = 'clic page'; }); clickAtCursor(box); } Future isLoginFormVisible() async { const js = ''' (() => { return !!( document.querySelector('input[name="username"]') && document.querySelector('input[type="password"]') ); })(); '''; final result = await controller.runJavaScriptReturningResult(js); return result.toString().contains('true'); } Future runControlAction(TvAction action) async { switch (action) { case TvAction.scrollUp: await scrollPage(-760); break; case TvAction.scrollDown: await scrollPage(760); break; case TvAction.playSound: await playBestVideo(); break; case TvAction.fullscreen: await fullscreenBestVideo(); break; case TvAction.exitFullscreen: await exitVideoFullscreen(); break; } } Future scrollPage(int amount) async { setState(() { lastAction = amount > 0 ? 'scroll bas' : 'scroll haut'; }); final js = ''' (() => { const amount = $amount; window.scrollBy({ top: amount, left: 0, behavior: 'smooth' }); const candidates = [ document.scrollingElement, document.documentElement, document.body, ...Array.from(document.querySelectorAll('main, div, section')) ].filter(Boolean); candidates.forEach(el => { try { if (el.scrollHeight > el.clientHeight + 80) { el.scrollBy({ top: amount, left: 0, behavior: 'smooth' }); el.scrollTop += amount; } } catch(e) {} }); })(); '''; await controller.runJavaScript(js); } Future playBestVideo() async { setState(() { lastAction = 'play + son'; }); const js = ''' (() => { const v = window.__instaTvGetBestVideo?.() || Array.from(document.querySelectorAll('video'))[0]; if (!v) return; v.muted = false; v.defaultMuted = false; v.volume = 1; v.playsInline = false; v.setAttribute('playsinline', 'false'); const r = v.getBoundingClientRect(); const x = Math.round(r.left + r.width / 2); const y = Math.round(r.top + r.height / 2); ['pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click'].forEach(type => { v.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); }); if (v.paused) { v.play().catch(() => {}); } const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); const muteBtn = buttons.find(b => { const txt = (b.innerText || b.getAttribute('aria-label') || '').toLowerCase(); return txt.includes('unmute') || txt.includes('activer le son') || txt.includes('sound') || txt.includes('audio') || txt.includes('son'); }); if (muteBtn) muteBtn.click(); })(); '''; await controller.runJavaScript(js); } Future fullscreenBestVideo() async { setState(() { videoFullscreen = true; lastAction = 'fullscreen'; }); const js = ''' (() => { document.querySelectorAll('video.__instaTvFullscreenVideo').forEach(v => { v.classList.remove('__instaTvFullscreenVideo'); }); const v = window.__instaTvGetBestVideo?.() || Array.from(document.querySelectorAll('video'))[0]; if (!v) return; v.muted = false; v.defaultMuted = false; v.volume = 1; v.playsInline = false; v.setAttribute('playsinline', 'false'); v.classList.add('__instaTvFullscreenVideo'); document.body.classList.add('__instaTvNoScroll'); setTimeout(() => { v.play().catch(() => {}); }, 250); })(); '''; await controller.runJavaScript(js); } Future exitVideoFullscreen() async { setState(() { videoFullscreen = false; lastAction = 'sortie fullscreen'; }); const js = ''' (() => { document.querySelectorAll('video.__instaTvFullscreenVideo').forEach(v => { v.classList.remove('__instaTvFullscreenVideo'); }); document.body.classList.remove('__instaTvNoScroll'); })(); '''; await controller.runJavaScript(js); } void openOverlayInput(EditTarget target) { setState(() { editTarget = target; inputController.text = target == EditTarget.username ? usernameValue : passwordValue; lastAction = target == EditTarget.username ? 'édition identifiant' : 'édition mot de passe'; }); WidgetsBinding.instance.addPostFrameCallback((_) { inputFocusNode.requestFocus(); SystemChannels.textInput.invokeMethod('TextInput.show'); }); } Future saveOverlayInput() async { final target = editTarget; if (target == null) return; final value = inputController.text; if (target == EditTarget.username) { usernameValue = value; await injectLoginValue(value, isPassword: false); } else { passwordValue = value; await injectLoginValue(value, isPassword: true); } setState(() { editTarget = null; lastAction = 'texte injecté'; }); mainFocusNode.requestFocus(); } void cancelOverlayInput() { setState(() { editTarget = null; lastAction = 'saisie annulée'; }); mainFocusNode.requestFocus(); } Future injectLoginValue(String value, {required bool isPassword}) async { final encodedValue = jsonEncode(value); final target = isPassword ? 'password' : 'username'; final js = ''' (() => { const value = $encodedValue; const target = '$target'; let el = null; if (target === 'password') { el = document.querySelector('input[type="password"]'); } else { el = document.querySelector('input[name="username"]') || document.querySelector('input[autocomplete="username"]') || document.querySelector('input[type="text"]') || document.querySelector('input'); } if (!el) return; const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set; if (setter) { setter.call(el, value); } else { el.value = value; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.blur(); })(); '''; await controller.runJavaScript(js); } Future clickAtCursor(BoxConstraints box) async { final clickX = cursorX + cursorSize / 2; final clickY = cursorY + cursorSize / 2; final js = ''' (() => { const x = Math.round($clickX / ${box.maxWidth} * window.innerWidth); const y = Math.round($clickY / ${box.maxHeight} * window.innerHeight); let el = document.elementFromPoint(x, y); if (!el) return; const video = el.closest('video'); if (video) { video.muted = false; video.volume = 1; ['pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click'].forEach(type => { video.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); }); if (video.paused) { video.play().catch(() => {}); } return; } const clickable = el.closest('a, button, [role="button"]') || el; clickable.focus(); ['pointerover', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click'].forEach(type => { clickable.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); }); if (clickable.tagName === 'A' && clickable.href) { window.location.href = clickable.href; } else { clickable.click(); } })(); '''; await controller.runJavaScript(js); } Future goBack() async { if (editTarget != null) { cancelOverlayInput(); return; } if (videoFullscreen) { await exitVideoFullscreen(); return; } if (await controller.canGoBack()) { await controller.goBack(); } else { await controller.loadRequest( Uri.parse('https://www.instagram.com/accounts/login/?hl=fr'), ); } } Widget buildControls() { return Positioned( left: controlsLeft, top: controlsTop, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final control in controls) Container( width: controlWidth, height: controlHeight, margin: const EdgeInsets.only(bottom: controlGap), alignment: Alignment.center, decoration: BoxDecoration( color: Colors.black.withOpacity(0.78), borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.white30), ), child: Text( control.label, style: const TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold, ), ), ), ], ), ); } Widget buildInputOverlay() { final target = editTarget; if (target == null) return const SizedBox.shrink(); final isPassword = target == EditTarget.password; return Container( color: Colors.black.withOpacity(0.72), child: Center( child: Container( width: 760, padding: const EdgeInsets.all(28), decoration: BoxDecoration( color: const Color(0xFF202020), borderRadius: BorderRadius.circular(18), border: Border.all(color: Colors.white24), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( isPassword ? 'Mot de passe Instagram' : 'Identifiant Instagram', style: const TextStyle( color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 24), TextField( controller: inputController, focusNode: inputFocusNode, autofocus: true, obscureText: isPassword, style: const TextStyle(color: Colors.white, fontSize: 24), keyboardType: isPassword ? TextInputType.visiblePassword : TextInputType.text, decoration: const InputDecoration( filled: true, fillColor: Colors.white10, border: OutlineInputBorder(), hintText: 'Écrire ici', hintStyle: TextStyle(color: Colors.white54), ), onSubmitted: (_) => saveOverlayInput(), ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: cancelOverlayInput, child: const Text('Annuler', style: TextStyle(fontSize: 22)), ), const SizedBox(width: 18), FilledButton( onPressed: saveOverlayInput, child: const Text('Valider', style: TextStyle(fontSize: 22)), ), ], ), ], ), ), ), ); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Insta TV Viewer', debugShowCheckedModeBanner: false, home: Scaffold( backgroundColor: Colors.black, body: SafeArea( child: LayoutBuilder( builder: (context, box) { return Focus( autofocus: true, focusNode: mainFocusNode, onKeyEvent: (node, event) => handleMainKey(event, box), child: Stack( children: [ IgnorePointer(child: WebViewWidget(controller: controller)), buildControls(), Positioned( left: 12, top: 12, child: Container( padding: const EdgeInsets.all(8), color: Colors.black.withOpacity(0.75), child: Text( 'Action: $lastAction', style: const TextStyle(color: Colors.white, fontSize: 14), ), ), ), if (editTarget == null) Positioned( left: cursorX, top: cursorY, child: Container( width: cursorSize, height: cursorSize, decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), ), ), buildInputOverlay(), ], ), ); }, ), ), ), ); } }