diff --git a/lib/widgets/post/embed_view_renderer.dart b/lib/widgets/post/embed_view_renderer.dart new file mode 100644 index 00000000..4ba51b53 --- /dev/null +++ b/lib/widgets/post/embed_view_renderer.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class EmbedViewRenderer extends HookConsumerWidget { + final SnPostEmbedView embedView; + final double? maxHeight; + final BorderRadius? borderRadius; + + const EmbedViewRenderer({ + super.key, + required this.embedView, + this.maxHeight = 400, + this.borderRadius, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + constraints: BoxConstraints(maxHeight: maxHeight ?? 400), + decoration: BoxDecoration( + borderRadius: borderRadius ?? BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withOpacity(0.3), + width: 1, + ), + color: colorScheme.surfaceContainerLowest, + ), + child: ClipRRect( + borderRadius: borderRadius ?? BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with embed info + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + border: Border( + bottom: BorderSide( + color: colorScheme.outline.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Icon( + embedView.renderer == PostEmbedViewRenderer.webView + ? Symbols.web + : Symbols.web, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _getUriDisplay(embedView.uri), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon( + Symbols.open_in_new, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () async { + final uri = Uri.parse(embedView.uri); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + tooltip: 'Open in browser', + ), + ], + ), + ), + + // WebView content + AspectRatio( + aspectRatio: embedView.aspectRatio ?? 1, + child: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(embedView.uri)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + useShouldOverrideUrlLoading: true, + useOnLoadResource: true, + supportZoom: false, + useWideViewPort: false, + loadWithOverviewMode: true, + builtInZoomControls: false, + displayZoomControls: false, + minimumFontSize: 12, + preferredContentMode: UserPreferredContentMode.MOBILE, + allowsBackForwardNavigationGestures: false, + allowsLinkPreview: false, + isInspectable: false, + applicationNameForUserAgent: 'Solian/3.0', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36 Solian/3.0', + ), + onWebViewCreated: (controller) { + // Configure webview settings + controller.addJavaScriptHandler( + handlerName: 'onHeightChanged', + callback: (args) { + // Handle dynamic height changes if needed + }, + ); + }, + onLoadStart: (controller, url) { + // Handle load start + }, + onLoadStop: (controller, url) async { + // Inject CSS to improve mobile display and remove borders + await controller.evaluateJavascript( + source: ''' + // Remove unwanted elements + var elements = document.querySelectorAll('nav, header, footer, .ads, .advertisement, .sidebar'); + for (var i = 0; i < elements.length; i++) { + elements[i].style.display = 'none'; + } + + // Remove borders from embedded content (YouTube, Vimeo, etc.) + var iframes = document.querySelectorAll('iframe'); + for (var i = 0; i < iframes.length; i++) { + iframes[i].style.border = 'none'; + iframes[i].style.borderRadius = '0'; + } + + // Remove borders from video elements + var videos = document.querySelectorAll('video'); + for (var i = 0; i < videos.length; i++) { + videos[i].style.border = 'none'; + videos[i].style.borderRadius = '0'; + } + + // Remove borders from any element that might have them + var allElements = document.querySelectorAll('*'); + for (var i = 0; i < allElements.length; i++) { + if (allElements[i].style.border) { + allElements[i].style.border = 'none'; + } + } + + // Improve readability + var body = document.body; + body.style.fontSize = '14px'; + body.style.lineHeight = '1.4'; + body.style.margin = '0'; + body.style.padding = '0'; + + // Handle dynamic content + var observer = new MutationObserver(function(mutations) { + // Remove borders from newly added elements + var newIframes = document.querySelectorAll('iframe'); + for (var i = 0; i < newIframes.length; i++) { + if (!newIframes[i].style.border || newIframes[i].style.border !== 'none') { + newIframes[i].style.border = 'none'; + newIframes[i].style.borderRadius = '0'; + } + } + var newVideos = document.querySelectorAll('video'); + for (var i = 0; i < newVideos.length; i++) { + if (!newVideos[i].style.border || newVideos[i].style.border !== 'none') { + newVideos[i].style.border = 'none'; + newVideos[i].style.borderRadius = '0'; + } + } + window.flutter_inappwebview.callHandler('onHeightChanged', document.body.scrollHeight); + }); + observer.observe(document.body, { childList: true, subtree: true }); + ''', + ); + }, + onLoadError: (controller, url, code, message) { + // Handle load errors + }, + onLoadHttpError: (controller, url, statusCode, description) { + // Handle HTTP errors + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + final uri = navigationAction.request.url; + if (uri != null && uri.toString() != embedView.uri) { + // Open external links in browser + // You might want to use url_launcher here + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + }, + onProgressChanged: (controller, progress) { + // Handle progress changes if needed + }, + onConsoleMessage: (controller, consoleMessage) { + // Handle console messages for debugging + debugPrint('WebView Console: ${consoleMessage.message}'); + }, + ), + ), + ], + ), + ), + ); + } + + String _getUriDisplay(String uri) { + try { + final parsedUri = Uri.parse(uri); + return parsedUri.host.isNotEmpty ? parsedUri.host : uri; + } catch (e) { + return uri; + } + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 81c5cdbc..f64b1b81 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -21,6 +21,7 @@ import 'package:island/widgets/post/post_item_screenshot.dart'; import 'package:island/widgets/post/post_award_sheet.dart'; import 'package:island/widgets/post/post_pin_sheet.dart'; import 'package:island/widgets/post/post_shared.dart'; +import 'package:island/widgets/post/embed_view_renderer.dart'; import 'package:island/widgets/safety/abuse_report_helper.dart'; import 'package:island/widgets/share/share_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -549,6 +550,12 @@ class PostItem extends HookConsumerWidget { translationSection: translationSection, renderingPadding: renderingPadding, ), + if (item.embedView != null) + EmbedViewRenderer( + embedView: item.embedView!, + maxHeight: 400, + borderRadius: BorderRadius.circular(12), + ).padding(horizontal: renderingPadding.horizontal, vertical: 8), if (isShowReference) ReferencedPostWidget(item: item, renderingPadding: renderingPadding), if (item.repliesCount > 0 && isEmbedReply)