Initial upload: insta-tv-viewer
This commit is contained in:
+937
@@ -0,0 +1,937 @@
|
||||
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<InstaTvApp> createState() => _InstaTvAppState();
|
||||
}
|
||||
|
||||
class _InstaTvAppState extends State<InstaTvApp> {
|
||||
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<TvControl> 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<void> 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<void> 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<bool> 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<void> 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<void> 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<void> 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<void> unmuteBestVideo() async {
|
||||
setState(() {
|
||||
lastAction = 'son on';
|
||||
});
|
||||
|
||||
const js = '''
|
||||
(() => {
|
||||
window.__instaTvClickUnmuteForBestVideo?.();
|
||||
})();
|
||||
''';
|
||||
|
||||
await controller.runJavaScript(js);
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user