diff --git a/README.md b/README.md index 33f8eff..d1cbee5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.2.0 + Version 2.3.0 Flutter Platforms Gitea diff --git a/lib/app/app_strings.dart b/lib/app/app_strings.dart index 186fa46..1072c70 100644 --- a/lib/app/app_strings.dart +++ b/lib/app/app_strings.dart @@ -36,6 +36,7 @@ class AppStrings { String get downloading => ru ? 'Скачивается' : 'Downloading'; String get failed => ru ? 'Ошибка' : 'Failed'; String get open => ru ? 'Открыть' : 'Open'; + String get copyLink => ru ? 'Копировать ссылку' : 'Copy link'; String get hidePost => ru ? 'Скрыть пост' : 'Hide post'; String get hiddenLocally => ru ? 'Пост скрыт локально' : 'Post hidden locally'; diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index baa7dcb..5ba95c1 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.2.0'; -const appBuildNumber = 39; +const appDisplayVersion = '2.3.0'; +const appBuildNumber = 40; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index a6aef3e..99f762d 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,16 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.3.0', + title: 'Artist Cloud Drives & External Video Mirrors', + bullets: [ + 'Cloud Mirrors & Drives: automatic detection and parsing of external links to Google Drive, MEGA, Dropbox, Pixeldrain, Catbox, MediaFire, GoFile, Bunkr, and direct video files from post descriptions, HTML, and embed data.', + 'Direct Video Playback & Download: resolves direct streaming URLs for Dropbox (?raw=1), Google Drive, Pixeldrain API, and Catbox with 1-tap playback and direct downloads.', + 'Cloud Mirrors Card: styled cards with service branding colors (MEGA Red, Google Drive Blue, Dropbox Blue, Pixeldrain Cyan) showing link type, direct stream button, external open, and copy link.', + 'Link-Only Posts Support: artist posts that only contain cloud drive links without direct server files are now displayed with an elegant CloudMediaHero banner instead of an empty view.', + 'Archive Password Detection: automatically extracts and displays archive passwords (Pass: ..., Password: ...) with 1-tap copy button.', + 'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.', + ], + ), LunarisChange( version: '2.2.0', title: 'Offline Mode Overhaul & Post Details Screen Redesign', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 35b2821..af9f3e2 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -7,6 +7,7 @@ export 'models/artist_work_query.dart'; export 'models/artist_tag.dart'; export 'models/artist_link.dart'; export 'models/artist_announcement.dart'; +export 'models/cloud_media_link.dart'; export 'models/download_task.dart'; export 'models/downloaded_media.dart'; export 'models/favorite.dart'; diff --git a/lib/backend/models/cloud_media_link.dart b/lib/backend/models/cloud_media_link.dart new file mode 100644 index 0000000..3b33dda --- /dev/null +++ b/lib/backend/models/cloud_media_link.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; + +enum CloudServiceType { + googleDrive, + mega, + dropbox, + pixeldrain, + catbox, + mediafire, + bunkr, + gofile, + terabox, + genericVideo, + other, +} + +class CloudMediaLink { + const CloudMediaLink({ + required this.url, + required this.service, + required this.title, + this.directStreamUrl, + this.isFolder = false, + this.isStreamable = false, + this.detectedPassword, + }); + + final String url; + final CloudServiceType service; + final String title; + final String? directStreamUrl; + final bool isFolder; + final bool isStreamable; + final String? detectedPassword; + + String get serviceName { + switch (service) { + case CloudServiceType.googleDrive: + return 'Google Drive'; + case CloudServiceType.mega: + return 'MEGA'; + case CloudServiceType.dropbox: + return 'Dropbox'; + case CloudServiceType.pixeldrain: + return 'Pixeldrain'; + case CloudServiceType.catbox: + return 'Catbox'; + case CloudServiceType.mediafire: + return 'MediaFire'; + case CloudServiceType.bunkr: + return 'Bunkr'; + case CloudServiceType.gofile: + return 'GoFile'; + case CloudServiceType.terabox: + return 'TeraBox'; + case CloudServiceType.genericVideo: + return 'Direct Video'; + case CloudServiceType.other: + return 'External Mirror'; + } + } + + Color get brandColor { + switch (service) { + case CloudServiceType.googleDrive: + return const Color(0xFF4285F4); // Google Blue + case CloudServiceType.mega: + return const Color(0xFFD9272E); // Mega Red + case CloudServiceType.dropbox: + return const Color(0xFF0061FF); // Dropbox Blue + case CloudServiceType.pixeldrain: + return const Color(0xFF00B4D8); // Cyan + case CloudServiceType.catbox: + return const Color(0xFFFF6F00); // Amber/Orange + case CloudServiceType.mediafire: + return const Color(0xFF1E88E5); // MediaFire Blue + case CloudServiceType.bunkr: + return const Color(0xFF9C27B0); // Purple + case CloudServiceType.gofile: + return const Color(0xFF26A69A); // Teal + case CloudServiceType.terabox: + return const Color(0xFF29B6F6); // Light Blue + case CloudServiceType.genericVideo: + return const Color(0xFFE91E63); // Pink + case CloudServiceType.other: + return const Color(0xFF78909C); // Slate + } + } + + IconData get iconData { + if (isFolder) return Icons.folder_zip_rounded; + if (isStreamable || service == CloudServiceType.genericVideo) { + return Icons.play_circle_filled_rounded; + } + switch (service) { + case CloudServiceType.googleDrive: + return Icons.add_to_drive_rounded; + case CloudServiceType.mega: + return Icons.cloud_circle_rounded; + case CloudServiceType.dropbox: + return Icons.cloud_done_rounded; + case CloudServiceType.pixeldrain: + case CloudServiceType.catbox: + case CloudServiceType.mediafire: + case CloudServiceType.bunkr: + case CloudServiceType.gofile: + case CloudServiceType.terabox: + return Icons.cloud_download_rounded; + case CloudServiceType.genericVideo: + return Icons.videocam_rounded; + case CloudServiceType.other: + return Icons.link_rounded; + } + } + + Map toJson() => { + 'url': url, + 'service': service.name, + 'title': title, + if (directStreamUrl != null) 'directStreamUrl': directStreamUrl, + 'isFolder': isFolder, + 'isStreamable': isStreamable, + if (detectedPassword != null) 'detectedPassword': detectedPassword, + }; + + factory CloudMediaLink.fromJson(Map json) { + final serviceStr = (json['service'] ?? '').toString(); + final service = CloudServiceType.values.firstWhere( + (e) => e.name == serviceStr, + orElse: () => CloudServiceType.other, + ); + return CloudMediaLink( + url: (json['url'] ?? '').toString(), + service: service, + title: (json['title'] ?? '').toString(), + directStreamUrl: json['directStreamUrl']?.toString(), + isFolder: json['isFolder'] == true, + isStreamable: json['isStreamable'] == true, + detectedPassword: json['detectedPassword']?.toString(), + ); + } + + String encode() => jsonEncode(toJson()); + + static CloudMediaLink? tryDecode(String str) { + try { + final decoded = jsonDecode(str); + if (decoded is Map) { + return CloudMediaLink.fromJson(decoded); + } + } catch (_) {} + return null; + } +} diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index 10e63dd..b175386 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -1,3 +1,6 @@ +import 'cloud_media_link.dart'; +import '../services/cloud_link_extractor.dart'; + class Post { const Post({ required this.id, @@ -35,6 +38,28 @@ class Post { String get cacheKey => '$providerId:$id'; + List get cloudLinks { + final list = tagGroups['cloud_links']; + if (list != null && list.isNotEmpty) { + return list + .map((e) => CloudMediaLink.tryDecode(e)) + .whereType() + .toList(growable: false); + } + if (source != null && source!.isNotEmpty) { + return CloudLinkExtractor.extractLinks(source: source); + } + return const []; + } + + String? get commentary { + final list = tagGroups['description']; + if (list != null && list.isNotEmpty) { + return list.first; + } + return null; + } + Post copyWith({ String? id, String? providerId, diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index b617614..e6143c9 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -10,6 +10,7 @@ import '../models/post.dart'; import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; +import '../services/cloud_link_extractor.dart'; import 'content_provider.dart'; class KemonoProvider @@ -361,6 +362,18 @@ class KemonoProvider _dateFromAny(json['added']) ?? _dateFromAny(json['edited']) ?? DateTime.fromMillisecondsSinceEpoch(0); + final postSource = + '$baseUrl/${query.service}/user/${query.artistId}/post/$postId'; + final rawContent = (json['content'] ?? '').toString(); + final embed = json['embed'] is Map ? Map.from(json['embed']) : null; + final cloudLinks = CloudLinkExtractor.extractLinks( + content: rawContent, + source: postSource, + embed: embed, + ); + final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false); + final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent); + final files = >[]; final file = json['file']; if (file is Map && file.isNotEmpty) { @@ -370,6 +383,47 @@ class KemonoProvider if (attachments is List) { files.addAll(attachments.whereType().map(Map.from)); } + + // Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.) + if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) { + final stableId = '${query.service}:${query.artistId}:$postId:0'; + final firstStreamable = + cloudLinks.where((l) => l.isStreamable).firstOrNull; + final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null; + final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? ''; + final tags = [ + query.service, + query.artistName, + if (title.trim().isNotEmpty) title.trim(), + 'cloud_mirror', + ]; + return [ + Post( + id: stableId, + providerId: id, + providerName: name, + previewUrl: '', + sampleUrl: '', + fileUrl: fileUrl, + tags: tags, + rating: 'unknown', + width: 0, + height: 0, + source: postSource, + createdAt: published, + fileType: firstStreamable != null ? 'video' : 'link', + score: 0, + tagGroups: { + 'artist': [query.artistName], + 'meta': [query.service], + if (title.trim().isNotEmpty) 'copyright': [title.trim()], + if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, + if (cleanDesc.isNotEmpty) 'description': [cleanDesc], + }, + ), + ]; + } + var index = 0; return files .map((file) { @@ -396,8 +450,7 @@ class KemonoProvider rating: 'unknown', width: 0, height: 0, - source: - '$baseUrl/${query.service}/user/${query.artistId}/post/$postId', + source: postSource, createdAt: published, fileType: type, score: 0, @@ -405,6 +458,8 @@ class KemonoProvider 'artist': [query.artistName], 'meta': [query.service], if (title.trim().isNotEmpty) 'copyright': [title.trim()], + if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, + if (cleanDesc.isNotEmpty) 'description': [cleanDesc], }, ); }) diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index cdab8bc..016faa5 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -12,6 +12,7 @@ import '../models/post.dart'; import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; +import '../services/cloud_link_extractor.dart'; import 'content_provider.dart'; class PawchiveProvider @@ -394,6 +395,18 @@ class PawchiveProvider _dateFromAny(json['added']) ?? _dateFromAny(json['edited']) ?? DateTime.fromMillisecondsSinceEpoch(0); + final postSource = + '$baseUrl/${query.service}/user/${query.artistId}/post/$postId'; + final rawContent = (json['content'] ?? '').toString(); + final embed = json['embed'] is Map ? Map.from(json['embed']) : null; + final cloudLinks = CloudLinkExtractor.extractLinks( + content: rawContent, + source: postSource, + embed: embed, + ); + final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false); + final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent); + final files = >[]; final file = json['file']; if (file is Map && file.isNotEmpty) { @@ -404,6 +417,46 @@ class PawchiveProvider files.addAll(attachments.whereType().map(Map.from)); } + // Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.) + if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) { + final stableId = '${query.service}:${query.artistId}:$postId:0'; + final firstStreamable = + cloudLinks.where((l) => l.isStreamable).firstOrNull; + final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null; + final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? ''; + final tags = [ + query.service, + query.artistName, + if (title.trim().isNotEmpty) title.trim(), + 'cloud_mirror', + ]; + return [ + Post( + id: stableId, + providerId: id, + providerName: name, + previewUrl: '', + sampleUrl: '', + fileUrl: fileUrl, + tags: tags, + rating: 'unknown', + width: 0, + height: 0, + source: postSource, + createdAt: published, + fileType: firstStreamable != null ? 'video' : 'link', + score: 0, + tagGroups: { + 'artist': [query.artistName], + 'meta': [query.service], + if (title.trim().isNotEmpty) 'copyright': [title.trim()], + if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, + if (cleanDesc.isNotEmpty) 'description': [cleanDesc], + }, + ), + ]; + } + var index = 0; return files .map((fileMap) { @@ -436,8 +489,7 @@ class PawchiveProvider rating: 'unknown', width: 0, height: 0, - source: - '$baseUrl/${query.service}/user/${query.artistId}/post/$postId', + source: postSource, createdAt: published, fileType: type, score: 0, @@ -445,6 +497,8 @@ class PawchiveProvider 'artist': [query.artistName], 'meta': [query.service], if (title.trim().isNotEmpty) 'copyright': [title.trim()], + if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, + if (cleanDesc.isNotEmpty) 'description': [cleanDesc], }, ); }) diff --git a/lib/backend/services/cloud_link_extractor.dart b/lib/backend/services/cloud_link_extractor.dart new file mode 100644 index 0000000..d61d990 --- /dev/null +++ b/lib/backend/services/cloud_link_extractor.dart @@ -0,0 +1,310 @@ +import '../models/cloud_media_link.dart'; + +class CloudLinkExtractor { + static final _urlRegex = RegExp( + r'''https?://[^\s<>"{}|\\^`\[\]]+''', + caseSensitive: false, + ); + + static final _htmlLinkRegex = RegExp( + r"""]*href=["']([^"']+)["'][^>]*>(.*?)""", + caseSensitive: false, + dotAll: true, + ); + + static final _passwordRegex = RegExp( + r'(?:pass(?:word)?|pw|пароль|пас)(?:[\s:=_-]+)([^\s,;<>"\n\r]{2,50})', + caseSensitive: false, + ); + + /// Extracts any archive password mentioned in the raw text/HTML. + static String? extractPassword(String? text) { + if (text == null || text.trim().isEmpty) return null; + final match = _passwordRegex.firstMatch(text); + if (match != null && match.groupCount >= 1) { + final pass = match.group(1)?.trim(); + if (pass != null && + pass.isNotEmpty && + !pass.startsWith('http') && + pass.toLowerCase() != 'none') { + return pass; + } + } + return null; + } + + /// Extracts all cloud media and video mirror links from text, HTML, and embeds. + static List extractLinks({ + String? content, + String? source, + Map? embed, + }) { + final results = []; + final seenUrls = {}; + final detectedPass = extractPassword(content); + + void addLink(String rawUrl, {String? customTitle}) { + final cleanUrl = _cleanUrl(rawUrl); + if (cleanUrl == null || seenUrls.contains(cleanUrl)) return; + seenUrls.add(cleanUrl); + + final link = _classifyLink(cleanUrl, customTitle: customTitle, password: detectedPass); + if (link != null) { + results.add(link); + } + } + + // 1. Process HTML anchors first to preserve anchor text / custom title + if (content != null && content.contains(''))) { + url = url.substring(0, url.length - 1); + } + final parsed = Uri.tryParse(url); + if (parsed == null || !parsed.hasScheme || (!parsed.scheme.startsWith('http'))) { + return null; + } + return url; + } + + static String _stripHtml(String? html) { + if (html == null) return ''; + return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + } + + static CloudMediaLink? _classifyLink( + String url, { + String? customTitle, + String? password, + }) { + final uri = Uri.tryParse(url); + if (uri == null) return null; + final host = uri.host.toLowerCase(); + final path = uri.path.toLowerCase(); + + // Google Drive + if (host.contains('drive.google.com')) { + final isFolder = path.contains('/folders/') || uri.queryParameters.containsKey('folder'); + String? directStreamUrl; + final fileIdMatch = RegExp(r'/file/d/([a-zA-Z0-9_-]+)').firstMatch(uri.path); + final idParam = fileIdMatch?.group(1) ?? uri.queryParameters['id']; + if (idParam != null && !isFolder) { + directStreamUrl = 'https://drive.google.com/uc?export=download&id=$idParam'; + } + return CloudMediaLink( + url: url, + service: CloudServiceType.googleDrive, + title: customTitle ?? (isFolder ? 'Google Drive Folder' : 'Google Drive File'), + directStreamUrl: directStreamUrl, + isFolder: isFolder, + isStreamable: directStreamUrl != null, + detectedPassword: password, + ); + } + + // MEGA + if (host.contains('mega.nz') || host.contains('mega.co.nz')) { + final isFolder = url.contains('/folder/') || url.contains('/#F!') || url.contains('#F!'); + return CloudMediaLink( + url: url, + service: CloudServiceType.mega, + title: customTitle ?? (isFolder ? 'MEGA Cloud Folder' : 'MEGA Video / Archive'), + directStreamUrl: null, // End-to-end encrypted; launches in MEGA app/browser + isFolder: isFolder, + isStreamable: false, + detectedPassword: password, + ); + } + + // Dropbox + if (host.contains('dropbox.com')) { + final isFolder = path.contains('/sh/') || path.contains('/folder'); + var direct = url; + if (direct.contains('?dl=0')) { + direct = direct.replaceAll('?dl=0', '?raw=1'); + } else if (!direct.contains('?raw=1') && !isFolder) { + direct = direct.contains('?') ? '$direct&raw=1' : '$direct?raw=1'; + } + return CloudMediaLink( + url: url, + service: CloudServiceType.dropbox, + title: customTitle ?? (isFolder ? 'Dropbox Folder' : 'Dropbox Media'), + directStreamUrl: isFolder ? null : direct, + isFolder: isFolder, + isStreamable: !isFolder, + detectedPassword: password, + ); + } + + // Pixeldrain + if (host.contains('pixeldrain.com')) { + final fileMatch = RegExp(r'/u/([a-zA-Z0-9_-]+)').firstMatch(uri.path); + final isList = path.contains('/l/'); + final fileId = fileMatch?.group(1); + final direct = fileId != null ? 'https://pixeldrain.com/api/file/$fileId' : null; + return CloudMediaLink( + url: url, + service: CloudServiceType.pixeldrain, + title: customTitle ?? (isList ? 'Pixeldrain Gallery/List' : 'Pixeldrain Video'), + directStreamUrl: direct, + isFolder: isList, + isStreamable: direct != null, + detectedPassword: password, + ); + } + + // Catbox / Litterbox + if (host.contains('catbox.moe')) { + final isVideo = path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v'); + return CloudMediaLink( + url: url, + service: CloudServiceType.catbox, + title: customTitle ?? 'Catbox Upload', + directStreamUrl: url, + isFolder: false, + isStreamable: isVideo, + detectedPassword: password, + ); + } + + // MediaFire + if (host.contains('mediafire.com')) { + final isFolder = path.contains('/folder/') || path.contains('/?'); + return CloudMediaLink( + url: url, + service: CloudServiceType.mediafire, + title: customTitle ?? (isFolder ? 'MediaFire Folder' : 'MediaFire Download'), + directStreamUrl: null, + isFolder: isFolder, + isStreamable: false, + detectedPassword: password, + ); + } + + // Bunkr + if (host.contains('bunkr.')) { + final isAlbum = path.contains('/a/') || path.contains('/v/'); + return CloudMediaLink( + url: url, + service: CloudServiceType.bunkr, + title: customTitle ?? (isAlbum ? 'Bunkr Album' : 'Bunkr Media'), + directStreamUrl: null, + isFolder: isAlbum, + isStreamable: false, + detectedPassword: password, + ); + } + + // GoFile + if (host.contains('gofile.io')) { + return CloudMediaLink( + url: url, + service: CloudServiceType.gofile, + title: customTitle ?? 'GoFile Folder / Archive', + directStreamUrl: null, + isFolder: true, + isStreamable: false, + detectedPassword: password, + ); + } + + // TeraBox + if (host.contains('terabox') || host.contains('1024tera') || host.contains('dubox')) { + return CloudMediaLink( + url: url, + service: CloudServiceType.terabox, + title: customTitle ?? 'TeraBox Cloud Share', + directStreamUrl: null, + isFolder: true, + isStreamable: false, + detectedPassword: password, + ); + } + + // Direct Video Link (MP4, WEBM, MKV, MOV) + if (path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v') || path.endsWith('.mkv')) { + final filename = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'Video Stream'; + return CloudMediaLink( + url: url, + service: CloudServiceType.genericVideo, + title: customTitle ?? filename, + directStreamUrl: url, + isFolder: false, + isStreamable: true, + detectedPassword: password, + ); + } + + // Ignore known booru and search engine domains + if (host.contains('kemono') || + host.contains('coomer') || + host.contains('danbooru') || + host.contains('gelbooru') || + host.contains('google.com') && !host.contains('drive') || + host.contains('twitter.com') || + host.contains('x.com') || + host.contains('patreon.com') || + host.contains('fanbox.cc') || + host.contains('pixiv.net')) { + return null; + } + + return null; + } + + /// Extracts clean text (stripping HTML tags) suitable for author commentary display. + static String cleanCommentary(String? rawHtml) { + if (rawHtml == null || rawHtml.trim().isEmpty) return ''; + var text = rawHtml + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n') + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); + return text.trim(); + } +} diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 6c3276a..ce2842c 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -23,6 +23,7 @@ import '../../favorites/presentation/favorites_controller.dart'; import '../../feed/presentation/feed_controller.dart'; import '../../viewed/presentation/viewed_controller.dart'; import 'post_details_controller.dart'; +import 'widgets/cloud_mirrors_card.dart'; import 'widgets/post_action_bar.dart'; import 'widgets/post_media_viewer.dart'; import 'widgets/post_tags_panel.dart'; @@ -294,6 +295,24 @@ class PostDetailsScreen extends ConsumerWidget { localMedia: localMedia, fileSizeBytes: fileSizeBytes, ), + if (post.cloudLinks.isNotEmpty || + (post.commentary != null && + post.commentary!.trim().isNotEmpty)) ...[ + const SizedBox(height: 16), + CloudMirrorsCard( + links: post.cloudLinks, + strings: strings, + commentary: post.commentary, + onPlayStream: (streamUrl) => launchUrl( + Uri.parse(streamUrl), + mode: LaunchMode.externalApplication, + ), + onDownloadStream: settings.allowDownloads + ? (streamUrl) => + _downloadUrl(context, ref, post, streamUrl) + : null, + ), + ], const SizedBox(height: 16), Container( decoration: BoxDecoration( @@ -443,6 +462,23 @@ class PostDetailsScreen extends ConsumerWidget { localMedia: localMedia, fileSizeBytes: fileSizeBytes, ), + if (post.cloudLinks.isNotEmpty || + (post.commentary != null && + post.commentary!.trim().isNotEmpty)) ...[ + const SizedBox(height: 12), + CloudMirrorsCard( + links: post.cloudLinks, + strings: strings, + commentary: post.commentary, + onPlayStream: (streamUrl) => launchUrl( + Uri.parse(streamUrl), + mode: LaunchMode.externalApplication, + ), + onDownloadStream: settings.allowDownloads + ? (streamUrl) => _downloadUrl(context, ref, post, streamUrl) + : null, + ), + ], if (feedPosts != null && feedPosts.length > 1) ...[ const SizedBox(height: 12), _NeighborStrip( @@ -535,6 +571,16 @@ class PostDetailsScreen extends ConsumerWidget { }); } + Future _downloadUrl( + BuildContext context, + WidgetRef ref, + Post post, + String targetUrl, + ) async { + final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl); + await _download(context, ref, customPost); + } + Future _sharePost(BuildContext context, WidgetRef ref, Post post) async { final strings = ref.read(appStringsProvider); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart new file mode 100644 index 0000000..076ec39 --- /dev/null +++ b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../app/app_strings.dart'; +import '../../../../backend/models/cloud_media_link.dart'; + +class CloudMirrorsCard extends StatelessWidget { + const CloudMirrorsCard({ + super.key, + required this.links, + required this.strings, + this.commentary, + this.onPlayStream, + this.onDownloadStream, + }); + + final List links; + final AppStrings strings; + final String? commentary; + final ValueChanged? onPlayStream; + final ValueChanged? onDownloadStream; + + @override + Widget build(BuildContext context) { + if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final detectedPassword = links + .map((l) => l.detectedPassword) + .whereType() + .firstWhere((p) => p.isNotEmpty, orElse: () => ''); + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.2), + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + Icons.cloud_sync_rounded, + size: 20, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.ru + ? 'Облачные диски и зеркала' + : 'Cloud Mirrors & Drives', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + strings.ru + ? '${links.length} внешних источников' + : '${links.length} external sources', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (links.any((l) => l.isStreamable)) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.play_circle_fill_rounded, + size: 14, color: Colors.green), + const SizedBox(width: 4), + Text( + strings.ru ? 'Плеер доступен' : 'Streamable', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + + // Archive Password Banner + if (detectedPassword.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.key_rounded, size: 16, color: Colors.amber), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + children: [ + TextSpan( + text: detectedPassword, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded, size: 16), + visualDensity: VisualDensity.compact, + tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password', + onPressed: () { + Clipboard.setData(ClipboardData(text: detectedPassword)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + strings.ru + ? 'Пароль скопирован: $detectedPassword' + : 'Password copied: $detectedPassword', + ), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + ), + ], + + // Links list + if (links.isNotEmpty) ...[ + const SizedBox(height: 14), + ...links.map((link) => _buildLinkTile(context, link, strings)), + ], + + // Commentary / Description + if (commentary != null && commentary!.trim().isNotEmpty) ...[ + const SizedBox(height: 14), + Divider( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), + height: 1, + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.notes_rounded, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + strings.ru ? 'Описание от автора' : 'Artist Commentary', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + commentary!.trim(), + style: theme.textTheme.bodySmall?.copyWith( + height: 1.45, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } + + Widget _buildLinkTile( + BuildContext context, + CloudMediaLink link, + AppStrings strings, + ) { + final theme = Theme.of(context); + final brandColor = link.brandColor; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: brandColor.withValues(alpha: 0.25), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: brandColor.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all(color: brandColor.withValues(alpha: 0.35)), + ), + child: Icon(link.iconData, size: 20, color: brandColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + link.serviceName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + if (link.isFolder) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + strings.ru ? 'Папка' : 'Folder', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 10, + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + link.title, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + // Action buttons row + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button (if streamable) + if (link.isStreamable && onPlayStream != null) ...[ + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), + label: Text(strings.ru ? 'Смотреть' : 'Play'), + onPressed: () { + final target = link.directStreamUrl ?? link.url; + onPlayStream!(target); + }, + ), + const SizedBox(width: 8), + ], + // Download button (if direct streamable and callback exists) + if (link.isStreamable && + link.directStreamUrl != null && + onDownloadStream != null) ...[ + IconButton.filledTonal( + visualDensity: VisualDensity.compact, + tooltip: strings.download, + icon: const Icon(Icons.download_rounded, size: 16), + onPressed: () => onDownloadStream!(link.directStreamUrl!), + ), + const SizedBox(width: 8), + ], + // Copy link button + IconButton.outlined( + visualDensity: VisualDensity.compact, + tooltip: strings.copyLink, + icon: const Icon(Icons.copy_rounded, size: 16), + onPressed: () { + Clipboard.setData(ClipboardData(text: link.url)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + strings.ru + ? 'Ссылка скопирована: ${link.serviceName}' + : 'Link copied: ${link.serviceName}', + ), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + const SizedBox(width: 8), + // Open in browser / app button + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: brandColor, + foregroundColor: Colors.white, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + icon: const Icon(Icons.open_in_new_rounded, size: 15), + label: Text(strings.ru ? 'Открыть' : 'Open'), + onPressed: () => launchUrl( + Uri.parse(link.url), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 6b34cb0..a1a2ac2 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../../app/motion.dart'; import '../../../../backend/backend.dart'; @@ -210,7 +211,21 @@ class _PostMediaViewerState extends State ); } - final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; + if (_imageUrls.isEmpty) { + return _CloudMediaHero( + post: widget.post, + onOpenPrimary: () { + final links = widget.post.cloudLinks; + final first = links.isNotEmpty ? links.first : null; + if (first != null) { + launchUrl(Uri.parse(first.url), + mode: LaunchMode.externalApplication); + } + }, + ); + } + + final url = _imageUrls[_imageIndex]; final isLocal = url.startsWith('/') || url.startsWith('file://'); final headers = _headersFor(widget.post); final Widget image; @@ -419,7 +434,13 @@ class _PostMediaViewerState extends State } List _buildVideoUrls(Post post) { - final list = MediaUrlSelector.video(post); + final list = List.from(MediaUrlSelector.video(post)); + final cloudStreams = post.cloudLinks + .where((l) => l.isStreamable && l.directStreamUrl != null) + .map((l) => l.directStreamUrl!); + for (final stream in cloudStreams) { + if (!list.contains(stream)) list.add(stream); + } final local = widget.localFilePath; if (local != null && local.isNotEmpty && File(local).existsSync()) { return [local, ...list]; @@ -428,6 +449,7 @@ class _PostMediaViewerState extends State } bool _isVideo(Post post) { + if (post.cloudLinks.any((l) => l.isStreamable)) return true; final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); return value.contains('video') || value.contains('.webm') || @@ -2004,3 +2026,117 @@ class _VideoControls extends StatelessWidget { return '$minutes:$seconds'; } } + +class _CloudMediaHero extends StatelessWidget { + const _CloudMediaHero({ + required this.post, + this.onOpenPrimary, + }); + + final Post post; + final VoidCallback? onOpenPrimary; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru'; + final links = post.cloudLinks; + final primaryColor = links.isNotEmpty + ? links.first.brandColor + : theme.colorScheme.primary; + + return Center( + child: Container( + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + constraints: const BoxConstraints(maxWidth: 460), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: primaryColor.withValues(alpha: 0.35), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: primaryColor.withValues(alpha: 0.12), + blurRadius: 28, + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all( + color: primaryColor.withValues(alpha: 0.4), + width: 2, + ), + ), + child: Icon( + links.isNotEmpty ? links.first.iconData : Icons.cloud_queue_rounded, + size: 34, + color: primaryColor, + ), + ), + const SizedBox(height: 16), + Text( + isRu + ? 'Контент на внешнем диске' + : 'Cloud Drive Media', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + links.isNotEmpty + ? (isRu + ? 'Автор опубликовал медиа на ${links.map((l) => l.serviceName).toSet().join(', ')}.' + : 'Author hosted media on ${links.map((l) => l.serviceName).toSet().join(', ')}.') + : (isRu + ? 'В данном посте нет медиафайла на сервере.' + : 'No media file hosted directly on the server.'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (links.isNotEmpty) ...[ + const SizedBox(height: 20), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: Text( + isRu + ? 'Открыть ${links.first.serviceName}' + : 'Open ${links.first.serviceName}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: onOpenPrimary, + ), + ], + ], + ), + ), + ); + } +} + diff --git a/pubspec.yaml b/pubspec.yaml index 3225fa8..bda5087 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.2.0+39 +version: 2.3.0+40 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/cloud_link_extractor_test.dart b/test/backend/cloud_link_extractor_test.dart new file mode 100644 index 0000000..5c2b46a --- /dev/null +++ b/test/backend/cloud_link_extractor_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/cloud_media_link.dart'; +import 'package:gel_rule_app/backend/services/cloud_link_extractor.dart'; + +void main() { + group('CloudLinkExtractor tests', () { + test('extracts and parses Google Drive links with direct stream URLs', () { + const html = ''' +

New animation release!

+

Download here: Google Drive 4K

+

Or browse the full folder: https://drive.google.com/drive/folders/9ZyX-WvUtSrQpOnMlKjIhGfEdCbA456

+ '''; + + final links = CloudLinkExtractor.extractLinks(content: html); + expect(links.length, 2); + + final fileLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && !l.isFolder); + expect(fileLink.title, 'Google Drive 4K'); + expect(fileLink.isStreamable, isTrue); + expect(fileLink.directStreamUrl, 'https://drive.google.com/uc?export=download&id=1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123'); + + final folderLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && l.isFolder); + expect(folderLink.isFolder, isTrue); + expect(folderLink.isStreamable, isFalse); + }); + + test('extracts MEGA links and detects folders', () { + const content = ''' + Archive mirrors: + Mega file: https://mega.nz/file/xyz123#abcdefg + Mega folder: https://mega.nz/folder/abc456#1234567 + '''; + + final links = CloudLinkExtractor.extractLinks(content: content); + expect(links.length, 2); + + final fileLink = links.firstWhere((l) => !l.isFolder); + expect(fileLink.service, CloudServiceType.mega); + expect(fileLink.serviceName, 'MEGA'); + expect(fileLink.isFolder, isFalse); + + final folderLink = links.firstWhere((l) => l.isFolder); + expect(folderLink.service, CloudServiceType.mega); + expect(folderLink.isFolder, isTrue); + }); + + test('transforms Dropbox links into direct stream URLs with ?raw=1', () { + const content = 'https://www.dropbox.com/s/sample123/video.mp4?dl=0'; + final links = CloudLinkExtractor.extractLinks(content: content); + expect(links.length, 1); + final link = links.first; + expect(link.service, CloudServiceType.dropbox); + expect(link.isStreamable, isTrue); + expect(link.directStreamUrl, 'https://www.dropbox.com/s/sample123/video.mp4?raw=1'); + }); + + test('extracts Pixeldrain file IDs and generates API endpoints', () { + const content = 'Pixeldrain mirror: https://pixeldrain.com/u/abc123xy'; + final links = CloudLinkExtractor.extractLinks(content: content); + expect(links.length, 1); + final link = links.first; + expect(link.service, CloudServiceType.pixeldrain); + expect(link.isStreamable, isTrue); + expect(link.directStreamUrl, 'https://pixeldrain.com/api/file/abc123xy'); + }); + + test('extracts archive passwords from content', () { + expect( + CloudLinkExtractor.extractPassword('Here is the zip! Password: SuperSecret123'), + 'SuperSecret123', + ); + expect( + CloudLinkExtractor.extractPassword('Pass = pass_word_2026!'), + 'pass_word_2026!', + ); + expect( + CloudLinkExtractor.extractPassword('Пароль: mypass777'), + 'mypass777', + ); + }); + + test('cleans HTML commentary correctly', () { + const html = '

Hello world!
Check out the new video.

& enjoy!

'; + final clean = CloudLinkExtractor.cleanCommentary(html); + expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!'); + }); + + test('encodes and decodes CloudMediaLink to and from JSON', () { + const link = CloudMediaLink( + url: 'https://mega.nz/file/123#abc', + service: CloudServiceType.mega, + title: 'Full 4K Video', + isFolder: false, + isStreamable: false, + detectedPassword: 'pass', + ); + + final encoded = link.encode(); + final decoded = CloudMediaLink.tryDecode(encoded); + expect(decoded, isNotNull); + expect(decoded!.url, link.url); + expect(decoded.service, CloudServiceType.mega); + expect(decoded.title, 'Full 4K Video'); + expect(decoded.detectedPassword, 'pass'); + }); + }); +} diff --git a/test/features/cloud_mirrors_card_test.dart b/test/features/cloud_mirrors_card_test.dart new file mode 100644 index 0000000..0f963b2 --- /dev/null +++ b/test/features/cloud_mirrors_card_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/app/app_strings.dart'; +import 'package:gel_rule_app/backend/models/cloud_media_link.dart'; +import 'package:gel_rule_app/features/post/presentation/widgets/cloud_mirrors_card.dart'; + +void main() { + testWidgets('CloudMirrorsCard renders mirrors, password banner, and commentary', (tester) async { + const links = [ + CloudMediaLink( + url: 'https://mega.nz/file/xyz123#abc', + service: CloudServiceType.mega, + title: 'MEGA 4K Archive', + isFolder: false, + isStreamable: false, + detectedPassword: 'archive_pass_42', + ), + CloudMediaLink( + url: 'https://drive.google.com/file/d/123/view', + service: CloudServiceType.googleDrive, + title: 'Google Drive Stream', + directStreamUrl: 'https://drive.google.com/uc?export=download&id=123', + isFolder: false, + isStreamable: true, + ), + ]; + + var playedStream = ''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CloudMirrorsCard( + links: links, + strings: const AppStrings('ru'), + commentary: 'Special Patreon bonus animation for all patrons.', + onPlayStream: (stream) => playedStream = stream, + ), + ), + ), + ), + ); + + // Verify header and counts + expect(find.text('Облачные диски и зеркала'), findsOneWidget); + expect(find.text('2 внешних источников'), findsOneWidget); + expect(find.text('Плеер доступен'), findsOneWidget); + + // Verify password banner + expect(find.textContaining('archive_pass_42'), findsOneWidget); + + // Verify link titles and service names + expect(find.text('MEGA'), findsOneWidget); + expect(find.text('MEGA 4K Archive'), findsOneWidget); + expect(find.text('Google Drive'), findsOneWidget); + expect(find.text('Google Drive Stream'), findsOneWidget); + + // Verify commentary + expect(find.text('Описание от автора'), findsOneWidget); + expect(find.text('Special Patreon bonus animation for all patrons.'), findsOneWidget); + + // Verify play button triggers callback + final playBtn = find.text('Смотреть'); + expect(playBtn, findsOneWidget); + await tester.tap(playBtn); + expect(playedStream, 'https://drive.google.com/uc?export=download&id=123'); + }); + + testWidgets('CloudMirrorsCard renders nothing if no links and no commentary', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CloudMirrorsCard( + links: [], + strings: AppStrings('ru'), + ), + ), + ), + ); + + expect(find.byType(Container), findsNothing); + }); +}