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, playOnly, soundOn, 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 = 900; 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 controlsRight = 24; static const double controlsTop = 72; static const double controlWidth = 230; static const double controlHeight = 50; static const double controlGap = 10; final List controls = const [ TvControl('Post haut', TvAction.scrollUp), TvControl('Post bas', TvAction.scrollDown), TvControl('Play', TvAction.playOnly), TvControl('Son ON', TvAction.soundOn), ]; @override void initState() { super.initState(); final c = WebViewController(); if (c.platform is AndroidWebViewController) { final androidController = c.platform as AndroidWebViewController; androidController.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; }; window.__instaTvLastAutoVideo = null; window.__instaTvClickUnmuteForBestVideo = () => { const fireClick = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const x = r.left + r.width / 2; const y = r.top + r.height / 2; ['pointerover', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click'].forEach(type => { try { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: Math.round(x), clientY: Math.round(y) })); } catch(e) {} }); try { el.click(); } catch(e) {} return true; }; const getLabel = (el) => ( el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || el?.innerText || el?.querySelector?.('[aria-label]')?.getAttribute('aria-label') || '' ).toLowerCase(); const v = window.__instaTvGetBestVideo?.() || Array.from(document.querySelectorAll('video'))[0]; if (!v) return false; v.muted = false; v.defaultMuted = false; v.removeAttribute('muted'); v.volume = 1; const all = Array.from(document.querySelectorAll('[aria-label], button, [role="button"], svg')) .map(el => el.closest?.('button, [role="button"]') || el) .filter((el, i, arr) => el && arr.indexOf(el) === i); const button = all.find(el => { const label = getLabel(el); return label.includes('activer le son') || label.includes('réactiver le son') || label.includes('unmute') || label.includes('turn sound on') || label.includes('sound on') || label.includes('audio on'); }); if (button) { fireClick(button); } setTimeout(() => { v.muted = false; v.defaultMuted = false; v.removeAttribute('muted'); v.volume = 1; }, 250); return !!button; }; window.__instaTvAutoUnmuteCurrentVideo = () => { const v = window.__instaTvGetBestVideo?.(); if (!v) return; if (window.__instaTvLastAutoVideo !== v) { window.__instaTvLastAutoVideo = v; setTimeout(() => window.__instaTvClickUnmuteForBestVideo?.(), 250); setTimeout(() => window.__instaTvClickUnmuteForBestVideo?.(), 900); } }; setInterval(() => { window.__instaTvAutoUnmuteCurrentVideo?.(); }, 1200); window.__instaTvGetScrollTarget = () => { const candidates = [ document.scrollingElement, document.documentElement, document.body, ...Array.from(document.querySelectorAll('main, section, div')) ].filter(Boolean); let best = document.scrollingElement || document.documentElement || document.body; let bestScore = 0; for (const el of candidates) { try { const r = el.getBoundingClientRect(); const canScroll = el.scrollHeight > el.clientHeight + 120; const visible = r.height > 300 && r.width > 250; if (!canScroll || !visible) continue; const score = r.height * r.width; if (score > bestScore) { best = el; bestScore = score; } } catch(e) {} } 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(BoxConstraints box) { final centerX = cursorX + cursorSize / 2; final centerY = cursorY + cursorSize / 2; final left = box.maxWidth - controlsRight - controlWidth; final right = box.maxWidth - controlsRight; for (int i = 0; i < controls.length; i++) { final top = controlsTop + i * (controlHeight + controlGap); final bottom = top + controlHeight; if (centerX >= left && centerX <= right && centerY >= top && centerY <= bottom) { return controls[i].action; } } return null; } Future handleOk(BoxConstraints box) async { final action = hitTestControl(box); if (action != null) { await runControlAction(action); return; } final loginVisible = await isLoginFormVisible(); final yRatio = cursorY / box.maxHeight; if (loginVisible && cursorX > 260 && yRatio >= 0.30 && yRatio < 0.46) { openOverlayInput(EditTarget.username); return; } if (loginVisible && cursorX > 260 && 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 scrollOnePost(up: true); break; case TvAction.scrollDown: await scrollOnePost(up: false); break; case TvAction.playOnly: await playBestVideo(); break; case TvAction.soundOn: await unmuteBestVideo(); break; case TvAction.fullscreen: await fullscreenBestVideo(); break; case TvAction.exitFullscreen: await exitVideoFullscreen(); break; } } Future scrollOnePost({required bool up}) async { setState(() { lastAction = up ? 'post haut' : 'post bas'; }); final direction = up ? -1 : 1; final js = ''' (() => { const direction = $direction; const posts = Array.from(document.querySelectorAll('article, video')) .map(el => el.closest('article') || el) .filter((el, index, arr) => el && arr.indexOf(el) === index) .map(el => { const r = el.getBoundingClientRect(); return { el, top: r.top, bottom: r.bottom, height: r.height }; }) .filter(item => item.height > 180); let targetItem = null; if (direction > 0) { targetItem = posts.find(item => item.top > window.innerHeight * 0.35); } else { const previous = posts.filter(item => item.bottom < window.innerHeight * 0.65); targetItem = previous.length ? previous[previous.length - 1] : null; } if (targetItem) { targetItem.el.scrollIntoView({ block: 'center', behavior: 'smooth' }); return; } const target = window.__instaTvGetScrollTarget?.() || document.scrollingElement || document.documentElement || document.body; target.scrollBy({ top: direction * 430, left: 0, behavior: 'smooth' }); })(); '''; await controller.runJavaScript(js); await Future.delayed(const Duration(milliseconds: 900)); await controller.runJavaScript('window.__instaTvAutoUnmuteCurrentVideo?.();'); } Future playBestVideo() async { setState(() { lastAction = 'play'; }); const js = ''' (() => { const fireClick = (el, x, y) => { if (!el) return; ['pointerover', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click'].forEach(type => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: Math.round(x), clientY: Math.round(y) })); }); try { el.click(); } catch(e) {} }; const v = window.__instaTvGetBestVideo?.() || Array.from(document.querySelectorAll('video'))[0]; if (!v) return; const r = v.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; if (v.paused) { fireClick(v, cx, cy); v.play().catch(() => {}); } setTimeout(() => window.__instaTvClickUnmuteForBestVideo?.(), 250); setTimeout(() => window.__instaTvClickUnmuteForBestVideo?.(), 850); })(); '''; await controller.runJavaScript(js); } Future unmuteBestVideo() async { setState(() { lastAction = 'son on'; }); const js = ''' (() => { window.__instaTvClickUnmuteForBestVideo?.(); })(); '''; 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.playsInline = false; v.setAttribute('playsinline', 'false'); v.classList.add('__instaTvFullscreenVideo'); document.body.classList.add('__instaTvNoScroll'); if (v.paused) { v.play().catch(() => {}); } })(); '''; 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) { 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( right: controlsRight, 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(), ], ), ); }, ), ), ), ); } }