From 45faa12133f4d9375bc91ecc3fdecb71f4532cbe Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 16:40:37 +0300 Subject: [PATCH] feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements --- .gitignore | 1 + lib/app/app.dart | 57 +- lib/app/app_strings.dart | 6 +- lib/app/changelog.dart | 16 +- lib/app/router.dart | 5 + lib/backend/di/backend_providers.dart | 7 + lib/backend/providers/content_provider.dart | 8 + lib/backend/providers/danbooru_provider.dart | 6 +- lib/backend/providers/e621_provider.dart | 9 +- lib/backend/providers/gelbooru_provider.dart | 18 +- lib/backend/providers/kemono_provider.dart | 47 +- lib/backend/providers/moebooru_provider.dart | 9 +- lib/backend/providers/provider_factory.dart | 16 +- lib/backend/providers/provider_manager.dart | 309 +- .../providers/realbooru_html_provider.dart | 341 ++ lib/backend/providers/realbooru_provider.dart | 126 - .../providers/rule34_paheal_provider.dart | 239 ++ lib/backend/providers/rule34_provider.dart | 11 + .../repositories/provider_repository.dart | 57 +- .../repositories/search_repository.dart | 5 +- .../services/download_manager_service.dart | 49 +- lib/backend/services/download_service.dart | 36 +- lib/backend/services/feed_service.dart | 9 +- lib/backend/services/search_service.dart | 2 +- lib/backend/services/settings_service.dart | 37 +- .../services/viewed_history_service.dart | 31 + .../presentation/favorites_screen.dart | 177 +- .../feed/presentation/feed_controller.dart | 160 +- .../feed/presentation/feed_screen.dart | 38 +- .../feed/presentation/feed_state.dart | 11 + .../presentation/widgets/feed_toolbar.dart | 34 + .../presentation/post_details_controller.dart | 41 +- .../presentation/post_details_screen.dart | 183 +- .../presentation/widgets/post_action_bar.dart | 10 + .../widgets/post_media_viewer.dart | 2934 ++++++++--------- .../presentation/provider_form_screen.dart | 4 +- .../presentation/search_controller.dart | 44 +- .../presentation/widgets/recent_searches.dart | 31 +- .../presentation/hidden_posts_screen.dart | 130 + .../presentation/settings_screen.dart | 49 +- .../presentation/viewed_controller.dart | 49 +- .../viewed/presentation/viewed_screen.dart | 135 +- lib/shared/widgets/app_search_bar.dart | 187 +- lib/shared/widgets/app_shell.dart | 25 +- lib/shared/widgets/post_card.dart | 283 +- pubspec.lock | 16 +- pubspec.yaml | 1 - test/backend/feed_tag_filter_test.dart | 8 + test/backend/isar_integration_test.dart | 41 +- test/backend/provider_manager_test.dart | 123 + test/backend/provider_parser_test.dart | 43 +- test/backend/search_service_test.dart | 4 +- test/shared/tag_input_search_bar_test.dart | 117 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 55 files changed, 4263 insertions(+), 2076 deletions(-) create mode 100644 lib/backend/providers/realbooru_html_provider.dart delete mode 100644 lib/backend/providers/realbooru_provider.dart create mode 100644 lib/backend/providers/rule34_paheal_provider.dart create mode 100644 lib/features/settings/presentation/hidden_posts_screen.dart diff --git a/.gitignore b/.gitignore index b911e20..dcb652d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ dist/ /isar.dll .idea/ +.vscode/ *.iml .flutter-plugins .flutter-plugins-dependencies diff --git a/lib/app/app.dart b/lib/app/app.dart index 93fa884..9dc5e4a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -221,6 +221,19 @@ class _DownloadPanel extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; final strings = ref.watch(appStringsProvider); + final manager = ref.read(downloadManagerServiceProvider); + final paused = manager.queuePaused; + final activeTasks = tasks + .where((task) => + task.status == DownloadTaskStatus.queued || + task.status == DownloadTaskStatus.running) + .toList(); + final finishedTasks = tasks + .where((task) => + task.status == DownloadTaskStatus.completed || + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.canceled) + .toList(); return Material( elevation: 12, borderRadius: BorderRadius.circular(16), @@ -232,6 +245,39 @@ class _DownloadPanel extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + Row( + children: [ + Text( + strings.download, + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + IconButton( + tooltip: paused ? 'Resume queue' : 'Pause queue', + onPressed: + paused ? manager.resumeQueue : manager.pauseQueue, + icon: Icon(paused + ? Icons.play_arrow_rounded + : Icons.pause_rounded), + ), + if (activeTasks.isNotEmpty) + IconButton( + tooltip: 'Cancel active', + onPressed: () { + for (final task in activeTasks) { + manager.cancel(task.id); + } + }, + icon: const Icon(Icons.cancel_rounded), + ), + if (finishedTasks.isNotEmpty) + IconButton( + tooltip: 'Clear finished', + onPressed: manager.clearFinished, + icon: const Icon(Icons.clear_all_rounded), + ), + ], + ), for (final task in tasks) ...[ Row( children: [ @@ -252,6 +298,15 @@ class _DownloadPanel extends ConsumerWidget { .retry(task.id), icon: const Icon(Icons.refresh_rounded), ), + if (task.status == DownloadTaskStatus.queued || + task.status == DownloadTaskStatus.running) + IconButton( + tooltip: 'Cancel', + onPressed: () => ref + .read(downloadManagerServiceProvider) + .cancel(task.id), + icon: const Icon(Icons.close_rounded), + ), ], ), const SizedBox(height: 6), @@ -344,7 +399,7 @@ class _StartupErrorScreen extends ConsumerWidget { const SizedBox(height: 16), Text( strings.ru - ? 'Не удалось открыть локальную базу' + ? '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044c \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0431\u0430\u0437\u0443' : 'Could not start local database', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, diff --git a/lib/app/app_strings.dart b/lib/app/app_strings.dart index 8e7acc8..186fa46 100644 --- a/lib/app/app_strings.dart +++ b/lib/app/app_strings.dart @@ -75,8 +75,8 @@ class AppStrings { String get hideViewed => ru ? 'Скрывать просмотренное' : 'Hide viewed posts'; String get smartBlacklist => ru ? 'Умный blacklist' : 'Smart blacklist'; String get whitelistedTags => ru ? 'Whitelist тегов' : 'Whitelisted tags'; - String get cacheMaxItems => ru ? 'Лимит кэша' : 'Cache max items'; - String get clearCache => ru ? 'Очистить кэш' : 'Clear cache'; + String get cacheMaxItems => ru ? 'Лимит кеша' : 'Cache max items'; + String get clearCache => ru ? 'Очистить кеш' : 'Clear cache'; String get clearViewed => ru ? 'Очистить историю' : 'Clear viewed history'; String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON'; String get importJson => ru ? 'Импорт JSON' : 'Import JSON'; @@ -98,5 +98,5 @@ class AppStrings { String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat'; String get fullscreen => ru ? 'На весь экран' : 'Fullscreen'; String get exitFullscreen => - ru ? 'Выйти из полного экрана' : 'Exit fullscreen'; + ru ? 'Выйти из полноэкранного режима' : 'Exit fullscreen'; } diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 2d87e65..8c8fe20 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -43,29 +43,27 @@ const lunarisChangelog = [ ), LunarisChange( version: '1.1.0', - title: 'Lunaris rebrand and CosBooru', + title: 'Lunaris rebrand and providers', bullets: [ 'RuleGel is now Lunaris while keeping the same package identity for updates.', - 'CosBooru now uses the working Danbooru-compatible API at cos.lycore.co.', 'Xbooru was added as a default Gelbooru-compatible provider.', - 'Rule34 Paheal was removed from providers.', + 'Rule34 Paheal is available as an HTML provider.', 'Video player UI preferences are saved across fullscreen, details and restarts.', ], ), LunarisChange( version: '1.0.2', - title: 'Realbooru safety fix', + title: 'Provider safety fix', bullets: [ - 'Realbooru stays available in Providers, but is disabled by default while its public DAPI is offline.', - 'Provider diagnostics now surface the Realbooru API reason instead of a generic exception.', - 'Existing enabled Realbooru configs are disabled on startup so the main feed is not polluted by a known-offline API.', + 'Known-offline legacy providers are disabled on startup.', + 'Provider diagnostics now surface API reasons instead of generic exceptions.', + 'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.', ], ), LunarisChange( version: '1.0.1', - title: 'Realbooru and mobile polish', + title: 'Mobile polish', bullets: [ - 'Added Realbooru as a default enabled provider.', 'Startup update checks now look for new Gitea releases every launch.', 'Refresh-rate controls are Android-only; desktop keeps stable app motion.', 'Tag chip input no longer jumps while typing.', diff --git a/lib/app/router.dart b/lib/app/router.dart index be67fa8..751ca49 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart'; import '../features/providers/presentation/provider_form_screen.dart'; import '../features/providers/presentation/providers_screen.dart'; import '../features/search/presentation/search_screen.dart'; +import '../features/settings/presentation/hidden_posts_screen.dart'; import '../features/settings/presentation/settings_screen.dart'; import '../features/viewed/presentation/viewed_screen.dart'; import '../shared/widgets/app_shell.dart'; @@ -105,6 +106,10 @@ final appRouterProvider = Provider((ref) { path: '/settings', builder: (context, state) => const SettingsScreen(), ), + GoRoute( + path: '/settings/hidden', + builder: (context, state) => const HiddenPostsScreen(), + ), ], ), ], diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 8c3d9eb..931c6d3 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -12,6 +12,7 @@ import '../providers/provider_factory.dart'; import '../providers/provider_manager.dart'; import '../models/download_task.dart'; import '../models/downloaded_media.dart'; +import '../models/post.dart'; import '../repositories/collection_repository.dart'; import '../repositories/downloaded_media_repository.dart'; import '../repositories/favorite_repository.dart'; @@ -188,6 +189,12 @@ final downloadedMediaByKeyProvider = return result is Success ? result.data : null; }); +final postMediaHeadersProvider = + FutureProvider.family, Post>((ref, post) async { + final result = await ref.watch(providerManagerProvider).getMediaHeaders(post); + return result is Success> ? result.data : const {}; +}); + final downloadTasksProvider = StreamProvider>((ref) { final manager = ref.watch(downloadManagerServiceProvider); return manager.stream; diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart index e1bb399..3a8a12d 100644 --- a/lib/backend/providers/content_provider.dart +++ b/lib/backend/providers/content_provider.dart @@ -24,6 +24,14 @@ abstract class ContentProvider { Future checkHealth(); } +abstract class PostPageProvider { + String? postPageUrl(Post post); +} + +abstract class MediaHeadersProvider { + Map mediaHeaders(Post post); +} + abstract class CommentProvider { Future> getComments(String postId); } diff --git a/lib/backend/providers/danbooru_provider.dart b/lib/backend/providers/danbooru_provider.dart index a9daf6e..f6fb544 100644 --- a/lib/backend/providers/danbooru_provider.dart +++ b/lib/backend/providers/danbooru_provider.dart @@ -8,7 +8,8 @@ import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class DanbooruProvider implements ContentProvider, CommentProvider { +class DanbooruProvider + implements ContentProvider, CommentProvider, PostPageProvider { DanbooruProvider({ required this.id, required this.name, @@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider { final Dio _dio; final Map _queryParameters; + @override + String postPageUrl(Post post) => '$baseUrl/posts/${post.id}'; + @override Future> searchPosts({ required List tags, diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index f1f9303..bd84064 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -10,7 +10,11 @@ import '../models/top_period_filter.dart'; import 'content_provider.dart'; class E621Provider - implements ContentProvider, TagSuggestionProvider, CommentProvider { + implements + ContentProvider, + TagSuggestionProvider, + CommentProvider, + PostPageProvider { E621Provider({ required this.id, required this.name, @@ -29,6 +33,9 @@ class E621Provider final Dio _dio; final Map _queryParameters; + @override + String postPageUrl(Post post) => '$baseUrl/posts/${post.id}'; + @override Future> searchPosts({ required List tags, diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart index c00489b..cf322c1 100644 --- a/lib/backend/providers/gelbooru_provider.dart +++ b/lib/backend/providers/gelbooru_provider.dart @@ -15,7 +15,9 @@ class GelbooruProvider ContentProvider, CommentProvider, TagSuggestionProvider, - TagMetadataProvider { + TagMetadataProvider, + PostPageProvider, + MediaHeadersProvider { GelbooruProvider({ required this.id, required this.name, @@ -36,6 +38,20 @@ class GelbooruProvider Dio get dio => _dio; Map get queryParameters => _queryParameters; + @override + String postPageUrl(Post post) => + '$baseUrl/index.php?page=post&s=view&id=${post.id}'; + + @override + Map mediaHeaders(Post post) { + final host = Uri.tryParse(baseUrl)?.host; + return { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/', + }; + } + @override Future> searchPosts({ required List tags, diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index 4062ea0..0e4e381 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -106,10 +106,15 @@ class KemonoProvider 'itemsPerPage': limit.clamp(1, 100), }, ); - final artists = _artistsFromResponse(response.data) - .where((artist) => - service == null || service.isEmpty || artist.service == service) - .toList(growable: false); + final artists = _rankArtists( + _artistsFromResponse(response.data) + .where((artist) => + service == null || + service.isEmpty || + artist.service == service) + .toList(growable: false), + searchText, + ); if (artists.isNotEmpty) return artists; } catch (_) { // Fall through to the indexed creator endpoints. @@ -126,7 +131,7 @@ class KemonoProvider if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(), }, ); - return _artistsFromResponse(response.data); + return _rankArtists(_artistsFromResponse(response.data), query ?? ''); } catch (_) { return _listArtistsFromPublicApi( service: service, @@ -177,6 +182,7 @@ class KemonoProvider )); } if (mapped.isNotEmpty || posts.isEmpty) { + mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return mapped.take(limit).toList(growable: false); } } catch (error) { @@ -290,7 +296,36 @@ class KemonoProvider artist.displayName.toLowerCase().contains(queryText) || artist.id.toLowerCase().contains(queryText))) .toList(growable: false); - return items.skip(offset).take(limit).toList(growable: false); + final ranked = _rankArtists(items, queryText); + return ranked.skip(offset).take(limit).toList(growable: false); + } + + List _rankArtists(List artists, String query) { + final queryText = query.trim().toLowerCase(); + final ranked = [...artists]; + ranked.sort((a, b) { + final scoreA = _artistSearchScore(a, queryText); + final scoreB = _artistSearchScore(b, queryText); + if (scoreA != scoreB) return scoreB.compareTo(scoreA); + final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final updated = updatedB.compareTo(updatedA); + if (updated != 0) return updated; + final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0); + if (count != 0) return count; + return a.displayName.compareTo(b.displayName); + }); + return ranked; + } + + int _artistSearchScore(ArtistProfile artist, String query) { + if (query.isEmpty) return 0; + final name = artist.displayName.toLowerCase(); + final id = artist.id.toLowerCase(); + if (name == query || id == query) return 100; + if (name.startsWith(query) || id.startsWith(query)) return 70; + if (name.contains(query) || id.contains(query)) return 40; + return 0; } List _postItems(dynamic data) { diff --git a/lib/backend/providers/moebooru_provider.dart b/lib/backend/providers/moebooru_provider.dart index b06f2f5..c9e1822 100644 --- a/lib/backend/providers/moebooru_provider.dart +++ b/lib/backend/providers/moebooru_provider.dart @@ -10,7 +10,11 @@ import '../models/top_period_filter.dart'; import 'content_provider.dart'; class MoebooruProvider - implements ContentProvider, TagSuggestionProvider, CommentProvider { + implements + ContentProvider, + TagSuggestionProvider, + CommentProvider, + PostPageProvider { MoebooruProvider({ required this.id, required this.name, @@ -29,6 +33,9 @@ class MoebooruProvider final Dio _dio; final Map _queryParameters; + @override + String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}'; + @override Future> searchPosts({ required List tags, diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index b75e148..0604e1b 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -7,8 +7,9 @@ import 'e621_provider.dart'; import 'gelbooru_provider.dart'; import 'kemono_provider.dart'; import 'moebooru_provider.dart'; -import 'realbooru_provider.dart'; +import 'realbooru_html_provider.dart'; import 'rule34_provider.dart'; +import 'rule34_paheal_provider.dart'; class ProviderFactory { ContentProvider create(ContentProviderConfig config) { @@ -41,13 +42,20 @@ class ProviderFactory { dioClient: client, queryParameters: queryParameters, ); - case 'realbooru': - return RealbooruProvider( + case 'paheal': + case 'rule34_paheal': + return Rule34PahealProvider( + id: config.id, + name: config.name, + baseUrl: config.baseUrl, + dioClient: client, + ); + case 'realbooru_html': + return RealbooruHtmlProvider( id: config.id, name: config.name, baseUrl: config.baseUrl, dioClient: client, - queryParameters: queryParameters, ); case 'danbooru': return DanbooruProvider( diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index ac2f2c9..f9b889f 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -18,12 +18,30 @@ class ProviderManager { final ProviderRepository _repository; final ProviderFactory _factory; + final Map _suggestionCache = {}; + final Set _softRetryKeys = {}; + final _softRetryController = + StreamController.broadcast(); + _SuggestionProviderCacheEntry? _suggestionProviderCache; + + static const _suggestionCacheTtl = Duration(seconds: 45); + static const _suggestionProviderCacheTtl = Duration(seconds: 60); + static const _suggestionTimeout = Duration(milliseconds: 1500); + + Stream get softRetryResults => + _softRetryController.stream; Future>> loadConfigs({ bool enabledOnly = true, }) async { await _repository.ensureSeedProviders(); - return _repository.getProviders(enabledOnly: enabledOnly); + final result = await _repository.getProviders(enabledOnly: enabledOnly); + if (result is Error>) return result; + final configs = (result as Success>) + .data + .where((config) => !_isLegacyRemovedConfig(config)) + .toList(growable: false); + return Success(configs); } Future>> loadFeedConfigs({ @@ -97,20 +115,34 @@ class ProviderManager { Failure(code: 'not_found', message: 'Provider not found'), ); } - return _repository.saveProvider( + return _repository + .saveProvider( config.copyWith(enabled: enabled, updatedAt: DateTime.now()), - ); + ) + .then((result) { + _clearSuggestionCaches(); + return result; + }); }, onError: Error.new, ); } Future> addCustomProvider(ContentProviderConfig config) { - return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now())); + return _repository + .saveProvider(config.copyWith(updatedAt: DateTime.now())) + .then((result) { + _clearSuggestionCaches(); + return result; + }); } - Future> deleteProvider(String id) => - _repository.deleteProvider(id); + Future> deleteProvider(String id) { + return _repository.deleteProvider(id).then((result) { + _clearSuggestionCaches(); + return result; + }); + } Future>> checkAll({int concurrency = 3}) async { final providersResult = await activeProviders(); @@ -186,6 +218,14 @@ class ProviderManager { errorMessage: 'Search failed', ), ); + _scheduleSoftSearchRetry( + provider, + tags: tags, + page: page, + limit: limit, + rating: rating, + topPeriod: topPeriod, + ); } } final posts = providerId == null @@ -228,6 +268,75 @@ class ProviderManager { return hash; } + void _scheduleSoftSearchRetry( + ContentProvider provider, { + required List tags, + required int page, + required int limit, + required String? rating, + required TopPeriodFilter topPeriod, + }) { + final key = [ + provider.id, + page, + limit, + rating ?? '', + topPeriod.name, + ...tags, + ].join('\u001f'); + if (!_softRetryKeys.add(key)) return; + unawaited(Future.delayed(const Duration(milliseconds: 900), () async { + try { + final posts = await provider.searchPosts( + tags: tags, + page: page, + limit: limit, + rating: rating, + topPeriod: topPeriod, + ); + await _repository.saveDiagnostics( + ProviderDiagnostics( + providerId: provider.id, + lastSearchAt: DateTime.now(), + lastResultCount: posts.length, + ), + ); + await _repository.saveHealth( + ProviderHealth( + providerId: provider.id, + status: ProviderStatus.online, + pingMs: 0, + lastCheckedAt: DateTime.now(), + ), + ); + if (posts.isNotEmpty) { + _softRetryController.add( + ProviderSoftRetryResult( + providerId: provider.id, + tags: tags, + page: page, + limit: limit, + rating: rating, + topPeriod: topPeriod, + posts: posts, + ), + ); + } + } catch (_) { + await _repository.saveDiagnostics( + ProviderDiagnostics( + providerId: provider.id, + lastSearchAt: DateTime.now(), + lastResultCount: 0, + lastErrorMessage: 'Search failed after retry', + ), + ); + } finally { + _softRetryKeys.remove(key); + } + })); + } + Future>> getComments( String providerId, String postId, @@ -282,6 +391,39 @@ class ProviderManager { } } + Future> getPostPageUrl(Post post) async { + final providersResult = await activeProviders(); + if (providersResult is Error>) { + return Error(providersResult.failure); + } + final providers = (providersResult as Success>).data; + final matches = + providers.where((provider) => provider.id == post.providerId); + if (matches.isEmpty || matches.first is! PostPageProvider) { + return const Success(null); + } + return Success((matches.first as PostPageProvider).postPageUrl(post)); + } + + Future>> getMediaHeaders(Post post) async { + final providersResult = await activeProviders(); + if (providersResult is Error>) { + return Error(providersResult.failure); + } + final providers = (providersResult as Success>).data; + final matches = + providers.where((provider) => provider.id == post.providerId); + if (matches.isNotEmpty && matches.first is MediaHeadersProvider) { + return Success( + (matches.first as MediaHeadersProvider).mediaHeaders(post), + ); + } + return const Success({ + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + }); + } + Future> enrichPostTags(Post post) async { final hasUsefulGroups = post.tagGroups.entries.any( (entry) => entry.key != 'general' && entry.value.isNotEmpty, @@ -313,29 +455,106 @@ class ProviderManager { String query, { int limit = 20, }) async { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) return const Success([]); + final providersResult = await activeSuggestionProviders(); + if (providersResult is Error>) { + return Error(providersResult.failure); + } + final providers = (providersResult as Success>).data; + if (providers.isEmpty) return const Success([]); + + final cappedLimit = limit.clamp(1, 50); + final providerIds = providers.map((provider) => provider.id).join(','); + final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit'; + final cached = _suggestionCache[cacheKey]; + if (cached != null && + DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) { + return Success(cached.items.take(cappedLimit).toList(growable: false)); + } + + final providerResults = await Future.wait( + providers.map( + (provider) => _suggestionsFromProvider( + provider as TagSuggestionProvider, + normalizedQuery, + cappedLimit, + ), + ), + ); + final suggestions = {}; + for (final items in providerResults) { + for (final item in items) { + final nameKey = item.name.trim().toLowerCase(); + if (nameKey.isEmpty) continue; + final current = suggestions[nameKey]; + if (current == null || item.postCount > current.postCount) { + suggestions[nameKey] = item; + } + } + } + final values = suggestions.values.toList() + ..sort((a, b) { + final count = b.postCount.compareTo(a.postCount); + return count != 0 ? count : a.name.compareTo(b.name); + }); + _suggestionCache[cacheKey] = _SuggestionCacheEntry( + DateTime.now(), + values, + ); + _pruneSuggestionCache(); + return Success(values.take(cappedLimit).toList()); + } + + Future> _suggestionsFromProvider( + TagSuggestionProvider provider, + String query, + int limit, + ) async { + try { + return await provider + .suggestTags(query, limit: limit) + .timeout(_suggestionTimeout); + } catch (_) { + // Suggestions are non-critical; a failed provider should not affect UI. + return const []; + } + } + + void _pruneSuggestionCache() { + final now = DateTime.now(); + _suggestionCache.removeWhere( + (_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl, + ); + } + + Future>> activeSuggestionProviders() async { + final cached = _suggestionProviderCache; + if (cached != null && + DateTime.now().difference(cached.createdAt) < + _suggestionProviderCacheTtl) { + return Success(cached.providers); + } + final providersResult = await activeFeedProviders(); if (providersResult is Error>) { return Error(providersResult.failure); } - final providers = (providersResult as Success>) - .data - .whereType() - .toList(); - final suggestions = {}; - for (final provider in providers) { - try { - final items = await provider.suggestTags(query, limit: limit); - for (final item in items) { - suggestions.putIfAbsent( - '${item.providerId}:${item.name}', () => item); - } - } catch (_) { - // Suggestions are non-critical; a failed provider should not affect UI. - } - } - final values = suggestions.values.toList() - ..sort((a, b) => b.postCount.compareTo(a.postCount)); - return Success(values.take(limit).toList()); + final providers = [ + for (final provider + in (providersResult as Success>).data) + if (provider is TagSuggestionProvider) provider, + ]; + _suggestionProviderCache = _SuggestionProviderCacheEntry( + DateTime.now(), + providers, + ); + return Success(providers); + } + + void _clearSuggestionCaches() { + _suggestionCache.clear(); + _suggestionProviderCache = null; } Future> _runLimited( @@ -367,4 +586,44 @@ class ProviderManager { static bool _isFeedConfig(ContentProviderConfig config) { return !_isArtistConfig(config); } + + static bool _isLegacyRemovedConfig(ContentProviderConfig config) { + final id = config.id.toLowerCase(); + final type = config.apiType.toLowerCase(); + return id == 'cosbooru' || type == 'realbooru'; + } +} + +class _SuggestionCacheEntry { + const _SuggestionCacheEntry(this.createdAt, this.items); + + final DateTime createdAt; + final List items; +} + +class _SuggestionProviderCacheEntry { + const _SuggestionProviderCacheEntry(this.createdAt, this.providers); + + final DateTime createdAt; + final List providers; +} + +class ProviderSoftRetryResult { + const ProviderSoftRetryResult({ + required this.providerId, + required this.tags, + required this.page, + required this.limit, + required this.topPeriod, + required this.posts, + this.rating, + }); + + final String providerId; + final List tags; + final int page; + final int limit; + final TopPeriodFilter topPeriod; + final String? rating; + final List posts; } diff --git a/lib/backend/providers/realbooru_html_provider.dart b/lib/backend/providers/realbooru_html_provider.dart new file mode 100644 index 0000000..d882c1d --- /dev/null +++ b/lib/backend/providers/realbooru_html_provider.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../core/http/dio_client.dart'; +import '../models/post.dart'; +import '../models/provider_health.dart'; +import '../models/tag_suggestion.dart'; +import '../models/top_period_filter.dart'; +import 'content_provider.dart'; + +class RealbooruHtmlProvider + implements + ContentProvider, + PostPageProvider, + MediaHeadersProvider, + TagSuggestionProvider { + RealbooruHtmlProvider({ + required this.id, + required this.name, + required this.baseUrl, + required DioClient dioClient, + }) : _dio = dioClient.dio; + + @override + final String id; + @override + final String name; + @override + final String baseUrl; + final Dio _dio; + + @override + Future> searchPosts({ + required List tags, + required int page, + int limit = 50, + String? rating, + TopPeriodFilter topPeriod = TopPeriodFilter.none, + }) async { + final queryTags = [ + if (tags.isEmpty) 'all' else ...tags, + if (rating != null && rating.isNotEmpty) 'rating:$rating', + ].join(' '); + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'post', + 's': 'list', + 'tags': queryTags, + 'pid': page * limit, + }, + options: Options(responseType: ResponseType.plain), + ); + return _parseList(response.data ?? '').take(limit).toList(growable: false); + } + + @override + Future getPost(String id) async { + final response = await _dio.get( + '/index.php', + queryParameters: {'page': 'post', 's': 'view', 'id': id}, + options: Options(responseType: ResponseType.plain), + ); + return _parseDetails(id, response.data ?? ''); + } + + @override + Future checkHealth() async { + final startedAt = DateTime.now(); + try { + final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1); + return ProviderHealth( + providerId: id, + status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + apiVersion: 'realbooru-html', + errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null, + ); + } catch (error) { + return ProviderHealth( + providerId: id, + status: ProviderStatus.offline, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + errorMessage: error.toString(), + ); + } + } + + @override + String postPageUrl(Post post) => + '$baseUrl/index.php?page=post&s=view&id=${post.id}'; + + @override + Map mediaHeaders(Post post) => { + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36', + 'Accept': + 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer': postPageUrl(post), + }; + + @override + Future> suggestTags(String query, + {int limit = 20}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'autocomplete', + 'term': trimmed, + }, + ); + final values = _autocompleteItems(response.data); + return values + .map((name) => TagSuggestion( + name: name, + category: TagCategory.unknown, + postCount: 0, + providerId: id, + )) + .where((item) => item.name.toLowerCase().startsWith( + trimmed.toLowerCase(), + )) + .take(limit.clamp(1, 50)) + .toList(growable: false); + } + + List _parseList(String html) { + final posts = []; + final pattern = RegExp( + r''']*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?]+src=["']([^"']+)["'][^>]*>''', + caseSensitive: false, + ); + for (final match in pattern.allMatches(html)) { + final id = match.group(1) ?? ''; + final imgTag = match.group(0) ?? ''; + final previewUrl = _absolute(_decode(match.group(3) ?? '')); + final tags = _tags(_attribute(imgTag, 'title')); + final isVideo = imgTag.contains('#0000ff') || + tags.contains('webm') || + tags.contains('video'); + final isGif = + !isVideo && (tags.contains('gif') || tags.contains('animated_gif')); + final derivedOriginal = + isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif); + posts.add( + Post( + id: id, + providerId: this.id, + providerName: name, + previewUrl: previewUrl, + sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal, + fileUrl: isVideo ? '' : derivedOriginal, + tags: tags, + rating: 'explicit', + width: 0, + height: 0, + source: postPageUrl(Post( + id: id, + providerId: this.id, + providerName: name, + previewUrl: previewUrl, + sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal, + fileUrl: isVideo ? '' : derivedOriginal, + tags: tags, + rating: 'explicit', + width: 0, + height: 0, + createdAt: DateTime.now(), + fileType: isVideo + ? 'video' + : isGif + ? 'gif' + : 'image', + score: 0, + )), + createdAt: DateTime.now(), + fileType: isVideo + ? 'video' + : isGif + ? 'gif' + : 'image', + score: 0, + tagGroups: _tagGroups(tags), + ), + ); + } + return posts; + } + + Post? _parseDetails(String postId, String html) { + final imageTag = RegExp( + r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''', + caseSensitive: false, + ).firstMatch(html)?.group(0); + final originalUrl = _absolute( + RegExp( + r''']+href=["']([^"']+)["'][^>]*>\s*Original\s*''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + '', + ); + final videoSources = RegExp( + r''']+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''', + caseSensitive: false, + ) + .allMatches(html) + .map((match) => _absolute(match.group(1) ?? '')) + .where((url) => url.isNotEmpty) + .toList(); + videoSources.sort((left, right) { + final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm'); + final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm'); + if (leftWebm == rightWebm) return 0; + return leftWebm ? -1 : 1; + }); + final imageUrl = _absolute(_attribute(imageTag ?? '', 'src')); + final mediaUrl = videoSources.isNotEmpty + ? videoSources.first + : originalUrl.isNotEmpty + ? originalUrl + : imageUrl; + final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl; + final source = _attribute( + RegExp(r''']+id=["']source["'][^>]*>''', caseSensitive: false) + .firstMatch(html) + ?.group(0) ?? + '', + 'value', + ); + final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty + ? _attribute(imageTag ?? '', 'alt') + : RegExp( + r''']+id=["']tags["'][^>]*>([\s\S]*?)''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + ''; + final tags = _tags(tagsText); + if (mediaUrl.isEmpty) return null; + return Post( + id: postId, + providerId: id, + providerName: name, + previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl, + sampleUrl: sampleUrl, + fileUrl: mediaUrl, + tags: tags, + rating: 'explicit', + width: 0, + height: 0, + source: source.isEmpty ? postPageUrlById(postId) : source, + createdAt: DateTime.now(), + fileType: _fileType(mediaUrl), + score: 0, + tagGroups: _tagGroups(tags), + ); + } + + String postPageUrlById(String postId) => + '$baseUrl/index.php?page=post&s=view&id=$postId'; + + String _attribute(String source, String name) { + final match = RegExp( + '''$name\\s*=\\s*["']([^"']*)["']''', + caseSensitive: false, + ).firstMatch(source); + return _decode(match?.group(1) ?? ''); + } + + List _tags(String value) { + return _decode(value) + .replaceAll(',', ' ') + .split(RegExp(r'\s+')) + .map((tag) => tag.trim().replaceAll(' ', '_')) + .where((tag) => tag.isNotEmpty) + .toSet() + .toList(growable: false); + } + + Map> _tagGroups(List tags) => + tags.isEmpty ? const {} : {'general': tags}; + + String _fileType(String url) { + final lower = url.toLowerCase().split('?').first; + if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video'; + if (lower.endsWith('.gif')) return 'gif'; + return 'image'; + } + + String _absolute(String url) { + final value = _decode(url.trim()); + if (value.startsWith('//')) return _normalizeUrl('https:$value'); + if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value'); + return _normalizeUrl(value); + } + + String _normalizeUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url; + final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/'); + return uri.replace(path: normalizedPath).toString(); + } + + String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) { + final uri = Uri.tryParse(thumbnailUrl); + if (uri == null) return ''; + final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.') + .firstMatch(uri.path); + if (match == null) return ''; + final extension = gif ? 'gif' : 'jpeg'; + final path = + '/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension'; + return uri.replace(path: path, query: '').toString(); + } + + String _decode(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + } + + List _autocompleteItems(dynamic data) { + final source = data is String ? jsonDecode(data) : data; + if (source is List) { + return source + .map((item) => item.toString().trim().replaceAll(' ', '_')) + .where((item) => item.isNotEmpty) + .toSet() + .toList(growable: false); + } + return const []; + } +} diff --git a/lib/backend/providers/realbooru_provider.dart b/lib/backend/providers/realbooru_provider.dart deleted file mode 100644 index 88a0245..0000000 --- a/lib/backend/providers/realbooru_provider.dart +++ /dev/null @@ -1,126 +0,0 @@ -import '../../core/errors/app_exception.dart'; -import '../mappers/gelbooru_mapper.dart'; -import '../models/post.dart'; -import '../models/provider_health.dart'; -import '../models/top_period_filter.dart'; -import 'gelbooru_provider.dart'; - -class RealbooruProvider extends GelbooruProvider { - RealbooruProvider({ - required super.id, - required super.name, - required super.baseUrl, - required super.dioClient, - super.queryParameters, - }); - - @override - Future> searchPosts({ - required List tags, - required int page, - int limit = 50, - String? rating, - TopPeriodFilter topPeriod = TopPeriodFilter.none, - }) async { - final queryTags = [ - ...tags, - if (rating != null && rating.isNotEmpty) 'rating:$rating', - if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc', - ]; - final response = await dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'pid': page, - 'limit': limit.clamp(1, 100), - 'tags': queryTags.join(' '), - ...queryParameters, - }, - ); - _throwIfSearchFailed(response.data); - return GelbooruMapper.postsFromResponse( - response.data, - providerId: id, - providerName: name, - ); - } - - @override - Future getPost(String id) async { - final response = await dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'id': id, - ...queryParameters, - }, - ); - _throwIfSearchFailed(response.data); - final posts = GelbooruMapper.postsFromResponse( - response.data, - providerId: this.id, - providerName: name, - ); - return posts.isEmpty ? null : posts.first; - } - - @override - Future checkHealth() async { - final startedAt = DateTime.now(); - try { - final response = await dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'limit': 1, - ...queryParameters, - }, - ); - _throwIfSearchFailed(response.data); - return ProviderHealth( - providerId: id, - status: ProviderStatus.online, - pingMs: DateTime.now().difference(startedAt).inMilliseconds, - lastCheckedAt: DateTime.now(), - apiVersion: 'realbooru-dapi', - ); - } catch (error) { - return ProviderHealth( - providerId: id, - status: ProviderStatus.offline, - pingMs: DateTime.now().difference(startedAt).inMilliseconds, - lastCheckedAt: DateTime.now(), - errorMessage: error.toString(), - apiVersion: 'realbooru-dapi', - ); - } - } - - void _throwIfSearchFailed(dynamic data) { - if (data is Map) { - final success = data['success']?.toString().toLowerCase(); - if (success == 'false') { - throw ProviderUnavailableException( - (data['message'] ?? - data['reason'] ?? - 'Realbooru search is unavailable') - .toString(), - ); - } - } - final text = data?.toString().toLowerCase() ?? ''; - if (text.contains('success="false"') || text.contains("success='false'")) { - final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''') - .firstMatch(data?.toString() ?? ''); - throw ProviderUnavailableException( - message?.group(1) ?? 'Realbooru search is unavailable', - ); - } - } -} diff --git a/lib/backend/providers/rule34_paheal_provider.dart b/lib/backend/providers/rule34_paheal_provider.dart new file mode 100644 index 0000000..ec82ac8 --- /dev/null +++ b/lib/backend/providers/rule34_paheal_provider.dart @@ -0,0 +1,239 @@ +import 'package:dio/dio.dart'; + +import '../../core/http/dio_client.dart'; +import '../models/post.dart'; +import '../models/provider_health.dart'; +import '../models/top_period_filter.dart'; +import 'content_provider.dart'; + +class Rule34PahealProvider implements ContentProvider, PostPageProvider { + Rule34PahealProvider({ + required this.id, + required this.name, + required this.baseUrl, + required DioClient dioClient, + }) : _dio = dioClient.dio; + + @override + final String id; + @override + final String name; + @override + final String baseUrl; + final Dio _dio; + + @override + Future> searchPosts({ + required List tags, + required int page, + int limit = 50, + String? rating, + TopPeriodFilter topPeriod = TopPeriodFilter.none, + }) async { + // Paheal's public HTML list ignores common sort/top query parameters and + // exposes no stable top-period endpoint, so non-latest filters fall back + // to the regular list instead of pretending to support ranking. + final query = tags + .where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all') + .map((tag) => tag.trim().replaceAll(' ', '_')) + .join('_'); + final path = query.isEmpty + ? '/post/list/${page + 1}' + : '/post/list/$query/${page + 1}'; + final response = await _dio.get( + path, + options: Options(responseType: ResponseType.plain), + ); + return _parseList(response.data ?? '').take(limit).toList(growable: false); + } + + @override + Future getPost(String id) async { + final response = await _dio.get( + '/post/view/$id', + options: Options(responseType: ResponseType.plain), + ); + return _parseSingle(id, response.data ?? ''); + } + + @override + Future checkHealth() async { + final startedAt = DateTime.now(); + try { + final posts = await searchPosts(tags: const [], page: 0, limit: 1); + return ProviderHealth( + providerId: id, + status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + apiVersion: 'paheal-html', + errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null, + ); + } catch (error) { + return ProviderHealth( + providerId: id, + status: ProviderStatus.offline, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + errorMessage: error.toString(), + apiVersion: 'paheal-html', + ); + } + } + + @override + String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}'; + + List _parseList(String html) { + final posts = []; + final pattern = RegExp( + r''']+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?''', + caseSensitive: false, + ); + for (final match in pattern.allMatches(html)) { + final block = match.group(0) ?? ''; + final postId = _attribute(block, 'data-post-id'); + final fileUrl = _absolute( + RegExp( + r''']+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*''', + caseSensitive: false, + ).firstMatch(block)?.group(1) ?? + '', + ); + final previewUrl = _absolute( + RegExp(r''']+src=['"]([^'"]+)['"]''', caseSensitive: false) + .firstMatch(block) + ?.group(1) ?? + '', + ); + if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue; + final tags = _tags(_attribute(block, 'data-tags')); + final ext = _attribute(block, 'data-ext'); + final dimensions = _dimensions(_attribute(block, 'title')); + final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl; + posts.add( + Post( + id: postId, + providerId: id, + providerName: name, + previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl, + sampleUrl: mediaUrl, + fileUrl: mediaUrl, + tags: tags, + rating: 'explicit', + width: dimensions.$1, + height: dimensions.$2, + source: '$baseUrl/post/view/$postId', + createdAt: DateTime.now(), + fileType: _fileType('$ext $mediaUrl'), + score: 0, + tagGroups: tags.isEmpty ? const {} : {'general': tags}, + ), + ); + } + if (posts.isNotEmpty) return posts; + final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1); + final single = singleId == null ? null : _parseSingle(singleId, html); + return single == null ? const [] : [single]; + } + + Post? _parseSingle(String postId, String html) { + final fileUrl = _absolute( + RegExp( + r''']+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + RegExp( + r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + '', + ); + final previewUrl = _absolute( + RegExp( + r''']+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + '', + ); + final tagsText = RegExp( + r''']+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''', + caseSensitive: false, + ).firstMatch(html)?.group(1) ?? + ''; + final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl; + if (mediaUrl.isEmpty) return null; + final tags = _tags(tagsText); + return Post( + id: postId, + providerId: id, + providerName: name, + previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl, + sampleUrl: mediaUrl, + fileUrl: mediaUrl, + tags: tags, + rating: 'explicit', + width: 0, + height: 0, + source: '$baseUrl/post/view/$postId', + createdAt: DateTime.now(), + fileType: _fileType(mediaUrl), + score: 0, + tagGroups: tags.isEmpty ? const {} : {'general': tags}, + ); + } + + String _attribute(String source, String name) { + return _decode( + RegExp( + '''$name\\s*=\\s*['"]([^'"]*)['"]''', + caseSensitive: false, + ).firstMatch(source)?.group(1) ?? + '', + ); + } + + (int, int) _dimensions(String value) { + final match = RegExp(r'(\d+)x(\d+)').firstMatch(value); + return ( + int.tryParse(match?.group(1) ?? '') ?? 0, + int.tryParse(match?.group(2) ?? '') ?? 0, + ); + } + + List _tags(String value) { + return _decode(value) + .replaceAll(',', ' ') + .split(RegExp(r'\s+')) + .map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase()) + .where((tag) => tag.isNotEmpty) + .toSet() + .toList(growable: false); + } + + String _fileType(String value) { + final lower = value.toLowerCase().split('?').first; + if (lower.contains('webm') || lower.contains('mp4')) return 'video'; + if (lower.contains('gif')) return 'gif'; + if (lower.contains('png')) return 'png'; + return 'image'; + } + + String _absolute(String url) { + final value = _decode(url.trim()); + if (value.isEmpty) return ''; + if (value.startsWith('//')) return 'https:$value'; + if (value.startsWith('/')) return '$baseUrl$value'; + return value; + } + + String _decode(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + } +} diff --git a/lib/backend/providers/rule34_provider.dart b/lib/backend/providers/rule34_provider.dart index 434128d..c75bb0a 100644 --- a/lib/backend/providers/rule34_provider.dart +++ b/lib/backend/providers/rule34_provider.dart @@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider { super.queryParameters, }); + @override + String postPageUrl(Post post) => + 'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}'; + + @override + Map mediaHeaders(Post post) => const { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + 'Referer': 'https://rule34.xxx/', + }; + @override Future> searchPosts({ required List tags, diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 068e8e5..56b229a 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -111,8 +111,8 @@ class ProviderRepository { id: 'realbooru', name: 'Realbooru', baseUrl: 'https://realbooru.com', - apiType: 'realbooru', - enabled: false, + apiType: 'realbooru_html', + enabled: true, priority: 7, timeoutSeconds: 20, customHeaders: const {}, @@ -132,14 +132,16 @@ class ProviderRepository { updatedAt: now, ), ContentProviderConfig( - id: 'cosbooru', - name: 'CosBooru', - baseUrl: 'https://cos.lycore.co', - apiType: 'danbooru', + id: 'paheal', + name: 'Rule34 Paheal', + baseUrl: 'https://rule34.paheal.net', + apiType: 'paheal', enabled: true, priority: 9, timeoutSeconds: 20, - customHeaders: const {}, + customHeaders: const { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + }, createdAt: now, updatedAt: now, ), @@ -179,34 +181,12 @@ class ProviderRepository { final existingProviders = await isar.providerConfigEntitys.where().findAll(); for (final provider in existingProviders) { - if (provider.providerId == 'paheal') { - await isar.providerConfigEntitys.delete(provider.isarId); + if (provider.providerId == 'cosbooru') { + provider.enabled = false; + provider.updatedAt = DateTime.now(); + await isar.providerConfigEntitys.put(provider); } } - await isar.providerHealthEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); - await isar.providerDiagnosticsEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); - await isar.cachedPostEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); - await isar.favoriteEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); - await isar.collectionPostEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); - await isar.viewedPostEntitys - .filter() - .providerIdEqualTo('paheal') - .deleteAll(); final seeds = seedProviders(); for (final seed in seeds) { final exists = await isar.providerConfigEntitys @@ -216,13 +196,10 @@ class ProviderRepository { if (exists == null) { await isar.providerConfigEntitys .put(ProviderConfigEntity.fromModel(seed)); - } else if (seed.id == 'realbooru' && exists.enabled) { - exists.enabled = false; - exists.updatedAt = DateTime.now(); - await isar.providerConfigEntitys.put(exists); - } else if (seed.id == 'cosbooru' && - (exists.baseUrl != seed.baseUrl || - exists.apiType != seed.apiType)) { + } else if (seed.id == 'realbooru' && + (exists.apiType != seed.apiType || + exists.baseUrl != seed.baseUrl || + exists.name != seed.name)) { exists.name = seed.name; exists.baseUrl = seed.baseUrl; exists.apiType = seed.apiType; diff --git a/lib/backend/repositories/search_repository.dart b/lib/backend/repositories/search_repository.dart index 526d710..2f114e0 100644 --- a/lib/backend/repositories/search_repository.dart +++ b/lib/backend/repositories/search_repository.dart @@ -23,11 +23,12 @@ class SearchRepository { }); } - Future>> recent({int limit = 20}) { + Future>> recent({int? limit}) { return _databaseService.safeRead((isar) async { final items = await isar.searchHistoryEntitys.where().findAll(); items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); - return items.take(limit).map((entity) => entity.toModel()).toList(); + final limited = limit == null ? items : items.take(limit); + return limited.map((entity) => entity.toModel()).toList(); }); } diff --git a/lib/backend/services/download_manager_service.dart b/lib/backend/services/download_manager_service.dart index a635b8f..867c97a 100644 --- a/lib/backend/services/download_manager_service.dart +++ b/lib/backend/services/download_manager_service.dart @@ -16,9 +16,12 @@ class DownloadManagerService { final DownloadedMediaService? _downloadedMediaService; final _controller = StreamController>.broadcast(); final Map _tasks = {}; + final Set _runningTaskIds = {}; + bool _queuePaused = false; Stream> get stream => _controller.stream; List get tasks => List.unmodifiable(_tasks.values); + bool get queuePaused => _queuePaused; Future start(Post post) async { final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}'; @@ -30,7 +33,7 @@ class DownloadManagerService { status: DownloadTaskStatus.queued, ); _set(task); - unawaited(_run(task)); + _pumpQueue(); return task; } @@ -49,10 +52,21 @@ class DownloadManagerService { openAfterDownload: openAfterDownload, ); _set(task); - unawaited(_run(task)); + _pumpQueue(); return task; } + void pauseQueue() { + _queuePaused = true; + _emit(); + } + + void resumeQueue() { + _queuePaused = false; + _emit(); + _pumpQueue(); + } + Future retry(String taskId) async { final existing = _tasks[taskId]; if (existing == null) return; @@ -62,7 +76,7 @@ class DownloadManagerService { error: null, ); _set(task); - await _run(task); + _pumpQueue(); } void cancel(String taskId) { @@ -71,8 +85,19 @@ class DownloadManagerService { _set(existing.copyWith(status: DownloadTaskStatus.canceled)); } + void clearFinished() { + _tasks.removeWhere( + (_, task) => + task.status == DownloadTaskStatus.completed || + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.canceled, + ); + _emit(); + } + Future _run(DownloadTask task) async { if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return; + if (!_runningTaskIds.add(task.id)) return; _set(task.copyWith(status: DownloadTaskStatus.running)); try { final saved = task.sourceUrl == null @@ -121,14 +146,30 @@ class DownloadManagerService { error: error.toString(), ), ); + } finally { + _runningTaskIds.remove(task.id); + _pumpQueue(); } } void _set(DownloadTask task) { _tasks[task.id] = task; + _emit(); + } + + void _emit() { _controller.add(tasks); } + void _pumpQueue() { + if (_queuePaused) return; + for (final task in tasks) { + if (task.status != DownloadTaskStatus.queued) continue; + if (_runningTaskIds.contains(task.id)) continue; + unawaited(_run(task)); + } + } + void _updateProgress(String taskId, int received, int total) { if (total <= 0) return; final current = _tasks[taskId]; @@ -143,7 +184,7 @@ class DownloadManagerService { final current = _tasks[taskId]; if (current?.status != DownloadTaskStatus.completed) return; _tasks.remove(taskId); - _controller.add(tasks); + _emit(); }); } diff --git a/lib/backend/services/download_service.dart b/lib/backend/services/download_service.dart index 4eef136..301c42d 100644 --- a/lib/backend/services/download_service.dart +++ b/lib/backend/services/download_service.dart @@ -28,7 +28,12 @@ class DownloadService { if (Platform.isAndroid) { final tempDir = await getTemporaryDirectory(); final tempPath = p.join(tempDir.path, fileName); - await _dio.download(url, tempPath, onReceiveProgress: onProgress); + await _dio.download( + url, + tempPath, + onReceiveProgress: onProgress, + options: Options(headers: _headersFor(post)), + ); final saved = await _channel.invokeMethod('saveToDownloads', { 'path': tempPath, 'fileName': fileName, @@ -39,7 +44,12 @@ class DownloadService { final location = await getSaveLocation(suggestedName: fileName); if (location == null) return null; - await _dio.download(url, location.path, onReceiveProgress: onProgress); + await _dio.download( + url, + location.path, + onReceiveProgress: onProgress, + options: Options(headers: _headersFor(post)), + ); return location.path; } @@ -120,4 +130,26 @@ class DownloadService { if (value.contains('.png') || value.contains('png')) return 'image/png'; return 'image/jpeg'; } + + Map _headersFor(Post post) { + final lower = '${post.providerId} ${post.providerName} ' + '${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}' + .toLowerCase(); + return { + 'User-Agent': lower.contains('realbooru') || lower.contains('paheal') + ? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36' + : 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': lower.contains('realbooru') || lower.contains('paheal') + ? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8' + : '*/*', + if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/', + if (lower.contains('rule34') && !lower.contains('paheal')) + 'Referer': 'https://rule34.xxx/', + if (lower.contains('realbooru')) + 'Referer': + 'https://realbooru.com/index.php?page=post&s=view&id=${post.id}', + if (lower.contains('paheal')) + 'Referer': 'https://rule34.paheal.net/post/view/${post.id}', + }; + } } diff --git a/lib/backend/services/feed_service.dart b/lib/backend/services/feed_service.dart index 13afc02..e6dbce7 100644 --- a/lib/backend/services/feed_service.dart +++ b/lib/backend/services/feed_service.dart @@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List requestedTags) { .toList(growable: false); if (requested.isEmpty) return true; final postTags = _postTagSet(post); - return requested.every(postTags.contains); + return requested.every( + (requestedTag) => postTags.any( + (postTag) => + postTag == requestedTag || + postTag.startsWith('${requestedTag}_') || + postTag.startsWith('$requestedTag-'), + ), + ); } bool postPassesTagFilters(Post post, AppSettings settings) { diff --git a/lib/backend/services/search_service.dart b/lib/backend/services/search_service.dart index cb5ba6d..496085e 100644 --- a/lib/backend/services/search_service.dart +++ b/lib/backend/services/search_service.dart @@ -32,7 +32,7 @@ class SearchService { ); } - Future>> recentSearches({int limit = 20}) { + Future>> recentSearches({int? limit}) { return _repository.recent(limit: limit); } diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 4ca89b8..c6e3ec4 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -41,6 +41,7 @@ class AppSettings { required this.videoPlayerHalfVolume, required this.videoPlayerLoop, required this.videoPlayerCover, + required this.videoPlaybackPositions, required this.hiddenPostKeys, required this.diagnosticLogLines, required this.lastFeedTags, @@ -82,6 +83,7 @@ class AppSettings { final bool videoPlayerHalfVolume; final bool videoPlayerLoop; final bool videoPlayerCover; + final Map videoPlaybackPositions; final List hiddenPostKeys; final List diagnosticLogLines; final List lastFeedTags; @@ -108,7 +110,7 @@ class AppSettings { mobileColumns: 2, blurExplicitContent: true, allowDownloads: true, - autoDownloadFavorites: false, + autoDownloadFavorites: true, selectedFeedProviderIds: [], showPostBadges: true, defaultTopPeriodFilter: 'none', @@ -123,6 +125,7 @@ class AppSettings { videoPlayerHalfVolume: false, videoPlayerLoop: false, videoPlayerCover: false, + videoPlaybackPositions: {}, hiddenPostKeys: [], diagnosticLogLines: [], lastFeedTags: [], @@ -165,6 +168,7 @@ class AppSettings { bool? videoPlayerHalfVolume, bool? videoPlayerLoop, bool? videoPlayerCover, + Map? videoPlaybackPositions, List? hiddenPostKeys, List? diagnosticLogLines, List? lastFeedTags, @@ -212,6 +216,8 @@ class AppSettings { videoPlayerHalfVolume ?? this.videoPlayerHalfVolume, videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop, videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover, + videoPlaybackPositions: + videoPlaybackPositions ?? this.videoPlaybackPositions, hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys, diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines, lastFeedTags: lastFeedTags ?? this.lastFeedTags, @@ -256,6 +262,7 @@ class AppSettings { 'videoPlayerHalfVolume': videoPlayerHalfVolume, 'videoPlayerLoop': videoPlayerLoop, 'videoPlayerCover': videoPlayerCover, + 'videoPlaybackPositions': videoPlaybackPositions, 'hiddenPostKeys': hiddenPostKeys, 'diagnosticLogLines': diagnosticLogLines, 'lastFeedTags': lastFeedTags, @@ -334,6 +341,10 @@ class AppSettings { (json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop, videoPlayerCover: (json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover, + videoPlaybackPositions: Map.from( + (json['videoPlaybackPositions'] as Map?) ?? + defaults.videoPlaybackPositions, + ), hiddenPostKeys: List.from( (json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys, ), @@ -469,6 +480,30 @@ class SettingsService { return updateSettings(settings.copyWith(hiddenPostKeys: const [])); } + Future> saveVideoPlaybackPosition( + String cacheKey, + int milliseconds, { + int maxEntries = 300, + }) async { + if (cacheKey.isEmpty || milliseconds < 1000) return const Success(null); + final result = await getSettings(); + if (result is Error) return Error(result.failure); + final settings = (result as Success).data; + final existing = settings.videoPlaybackPositions[cacheKey] ?? 0; + if ((existing - milliseconds).abs() < 1000) return const Success(null); + final positions = { + ...settings.videoPlaybackPositions, + cacheKey: milliseconds, + }; + if (positions.length > maxEntries) { + final removeCount = positions.length - maxEntries; + for (final key in positions.keys.take(removeCount).toList()) { + positions.remove(key); + } + } + return updateSettings(settings.copyWith(videoPlaybackPositions: positions)); + } + Future> appendDiagnosticLog(String message) async { final result = await getSettings(); if (result is Error) return Error(result.failure); diff --git a/lib/backend/services/viewed_history_service.dart b/lib/backend/services/viewed_history_service.dart index 9b1906a..3f67971 100644 --- a/lib/backend/services/viewed_history_service.dart +++ b/lib/backend/services/viewed_history_service.dart @@ -35,7 +35,38 @@ class ViewedHistoryService { ); } + Future>> getViewedPostEntries() async { + final viewedResult = await _repository.recent(); + if (viewedResult is Error>) { + return Error(viewedResult.failure); + } + final viewed = (viewedResult as Success>).data; + final postsResult = await _postRepository.getCachedPostsByKeys( + viewed.map((item) => item.viewedKey), + ); + if (postsResult is Error>) { + return Error(postsResult.failure); + } + final postsByKey = { + for (final post in (postsResult as Success>).data) + post.cacheKey: post, + }; + return Success([ + for (final item in viewed) + if (postsByKey[item.viewedKey] != null) + ViewedPostEntry( + post: postsByKey[item.viewedKey]!, viewedAt: item.viewedAt), + ]); + } + Future> clearHistory() { return _repository.clear(); } } + +class ViewedPostEntry { + const ViewedPostEntry({required this.post, required this.viewedAt}); + + final Post post; + final DateTime viewedAt; +} diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index 099dcca..238b84b 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -11,11 +11,19 @@ import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/post_masonry_grid.dart'; import 'favorites_controller.dart'; -class FavoritesScreen extends ConsumerWidget { +class FavoritesScreen extends ConsumerStatefulWidget { const FavoritesScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _FavoritesScreenState(); +} + +class _FavoritesScreenState extends ConsumerState { + int _tabIndex = 0; + String? _selectedArtist; + + @override + Widget build(BuildContext context) { final state = ref.watch(favoritesControllerProvider); final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; @@ -33,28 +41,153 @@ class FavoritesScreen extends ConsumerWidget { error: (error, _) => ErrorView(message: error.toString()), data: (data) => data.posts.isEmpty ? const EmptyView(title: 'No favorites yet') - : PostMasonryGrid( - posts: data.posts, - columns: Responsive.columnsFor( - context, - mobileColumns: settings.mobileColumns, - desktopColumns: settings.desktopColumns, - ), - blurExplicit: settings.blurExplicitContent, - showBadges: settings.showPostBadges, - nsfwEnabled: settings.nsfwEnabled, - mediaQualityMode: - MediaQualityMode.fromName(settings.mediaQualityMode), - favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(), - downloadedKeys: downloaded.keys.toSet(), - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: post, - ), - onFavorite: (post) => - ref.read(favoritesControllerProvider.notifier).remove(post), + : Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 0, + icon: Icon(Icons.favorite_rounded), + label: Text('All'), + ), + ButtonSegment( + value: 1, + icon: Icon(Icons.offline_pin_rounded), + label: Text('Offline'), + ), + ButtonSegment( + value: 2, + icon: Icon(Icons.groups_rounded), + label: Text('Artists'), + ), + ], + selected: {_tabIndex}, + onSelectionChanged: (value) { + setState(() { + _tabIndex = value.first; + _selectedArtist = null; + }); + }, + ), + ), + Expanded( + child: switch (_tabIndex) { + 1 => _postsGrid( + data.posts + .where((post) => + downloaded.containsKey(post.cacheKey)) + .toList(), + settings, + downloaded.keys.toSet(), + ), + 2 => _artistAlbums( + data.posts, + settings, + downloaded.keys.toSet(), + ), + _ => _postsGrid( + data.posts, + settings, + downloaded.keys.toSet(), + ), + }, + ), + ], ), ), ); } + + Widget _postsGrid( + List posts, + AppSettings settings, + Set downloadedKeys, + ) { + if (posts.isEmpty) return const EmptyView(title: 'Nothing here yet'); + return PostMasonryGrid( + posts: posts, + columns: Responsive.columnsFor( + context, + mobileColumns: settings.mobileColumns, + desktopColumns: settings.desktopColumns, + ), + blurExplicit: settings.blurExplicitContent, + showBadges: settings.showPostBadges, + nsfwEnabled: settings.nsfwEnabled, + mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode), + favoriteKeys: posts.map((post) => post.cacheKey).toSet(), + downloadedKeys: downloadedKeys, + onOpen: (post) => context.push( + '/post/${post.providerId}/${post.id}', + extra: post, + ), + onFavorite: (post) => + ref.read(favoritesControllerProvider.notifier).remove(post), + ); + } + + Widget _artistAlbums( + List posts, + AppSettings settings, + Set downloadedKeys, + ) { + final groups = favoriteArtistAlbums(posts); + final selected = _selectedArtist; + if (selected != null) { + return Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => setState(() => _selectedArtist = null), + icon: const Icon(Icons.arrow_back_rounded), + label: Text(selected), + ), + ), + Expanded( + child: _postsGrid( + groups[selected] ?? const [], + settings, + downloadedKeys, + ), + ), + ], + ); + } + return ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + for (final entry in groups.entries) + Card( + child: ListTile( + leading: const Icon(Icons.person_rounded), + title: Text(entry.key), + subtitle: Text('${entry.value.length} posts'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => setState(() => _selectedArtist = entry.key), + ), + ), + ], + ); + } +} + +Map> favoriteArtistAlbums(List posts) { + final groups = >{}; + for (final post in posts) { + final artists = post.tagGroups['artist'] ?? + post.tags.where((tag) => tag.startsWith('artist:')).toList(); + final names = artists.isEmpty + ? const ['Unknown artist'] + : artists.map((tag) => tag.replaceFirst('artist:', '')).toList(); + for (final name in names) { + groups.putIfAbsent(name, () => []).add(post); + } + } + return Map.fromEntries( + groups.entries.toList() + ..sort((a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase())), + ); } diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index a2f21d5..e3ad844 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -12,11 +12,22 @@ final feedControllerProvider = class FeedController extends AsyncNotifier { Timer? _providerDebounce; + Timer? _suggestionDebounce; + StreamSubscription? _softRetrySubscription; int _loadRequestId = 0; + int _suggestionRequestId = 0; @override Future build() async { - ref.onDispose(() => _providerDebounce?.cancel()); + ref.onDispose(() { + _providerDebounce?.cancel(); + _suggestionDebounce?.cancel(); + _softRetrySubscription?.cancel(); + }); + _softRetrySubscription ??= ref + .read(providerManagerProvider) + .softRetryResults + .listen(_applySoftRetryResult); final providers = await _loadProviders(); final settings = await _settings(); final providerIds = providers.map((provider) => provider.id).toSet(); @@ -42,7 +53,13 @@ class FeedController extends AsyncNotifier { state = const AsyncLoading(); final posts = await _load(refresh: true, current: current); if (requestId != _loadRequestId) return; - state = AsyncData(current.copyWith(posts: posts, clearError: true)); + state = AsyncData(current.copyWith( + posts: posts, + hasMore: true, + emptyPageStreak: 0, + clearError: true, + providerStatusMessage: await _providerStatusMessage(current), + )); } Future refresh() async { @@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier { state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true)); final posts = await _load(refresh: true, current: current); if (requestId != _loadRequestId) return; - state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false)); + state = AsyncData(current.copyWith( + posts: posts, + isLoadingMore: false, + hasMore: true, + emptyPageStreak: 0, + providerStatusMessage: await _providerStatusMessage(current), + )); } Future loadNextPage() async { @@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier { if (current == null || current.isLoadingMore || !current.hasMore) return; final requestId = ++_loadRequestId; state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true)); - final posts = await _load(refresh: false, current: current); + final posts = []; + const maxEmptyPageSkips = 3; + var attempts = 0; + while (posts.isEmpty && attempts < maxEmptyPageSkips) { + attempts++; + posts.addAll(await _load(refresh: false, current: current)); + if (requestId != _loadRequestId) return; + } if (requestId != _loadRequestId) return; final existingKeys = current.posts.map((post) => post.cacheKey).toSet(); final newPosts = posts .where((post) => existingKeys.add(post.cacheKey)) .toList(growable: false); + final emptyPageStreak = + newPosts.isEmpty ? current.emptyPageStreak + attempts : 0; state = AsyncData( current.copyWith( posts: [...current.posts, ...newPosts], isLoadingMore: false, - hasMore: posts.isNotEmpty, + hasMore: newPosts.isNotEmpty || emptyPageStreak < maxEmptyPageSkips, + emptyPageStreak: emptyPageStreak, ), ); } @@ -78,7 +111,13 @@ class FeedController extends AsyncNotifier { final tags = ref.read(searchServiceProvider).parseTags(query); final current = state.value ?? const FeedState(); state = AsyncData( - current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), + current.copyWith( + selectedTags: tags, + posts: [], + tagSuggestions: [], + hasMore: true, + emptyPageStreak: 0, + ), ); await saveSession(scrollOffset: 0); await ref.read(searchServiceProvider).saveSearch(query, 0); @@ -89,31 +128,42 @@ class FeedController extends AsyncNotifier { Future updateTagSuggestions(String query) async { final current = state.value ?? const FeedState(); - if (query.trim().isEmpty) { + final lastToken = + query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; + final requestId = ++_suggestionRequestId; + _suggestionDebounce?.cancel(); + if (lastToken.trim().isEmpty) { state = AsyncData(current.copyWith(tagSuggestions: [])); return; } - final lastToken = query.trim().split(RegExp(r'\s+')).last; - final result = await ref - .read(searchServiceProvider) - .autocompleteDetailed(lastToken, limit: 16); - final normalizedToken = lastToken.trim().toLowerCase(); - final suggestions = result is Success> - ? result.data - .where((item) => item.name.toLowerCase().startsWith( - normalizedToken, - )) - .toList(growable: false) - : const []; - state = AsyncData(current.copyWith(tagSuggestions: suggestions)); + _suggestionDebounce = Timer(const Duration(milliseconds: 280), () async { + final result = await ref + .read(searchServiceProvider) + .autocompleteDetailed(lastToken, limit: 16); + if (requestId != _suggestionRequestId) return; + final normalizedToken = lastToken.trim().toLowerCase(); + final suggestions = result is Success> + ? result.data + .where((item) => item.name.toLowerCase().startsWith( + normalizedToken, + )) + .toList(growable: false) + : const []; + final latest = state.value ?? current; + state = AsyncData(latest.copyWith(tagSuggestions: suggestions)); + }); } Future setProviders(List providerIds) async { final current = state.value ?? const FeedState(); final enabledIds = current.providers.map((provider) => provider.id).toSet(); final selected = providerIds.where(enabledIds.contains).toList(); - state = - AsyncData(current.copyWith(selectedProviderIds: selected, posts: [])); + state = AsyncData(current.copyWith( + selectedProviderIds: selected, + posts: [], + hasMore: true, + emptyPageStreak: 0, + )); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( settings.copyWith( @@ -129,7 +179,12 @@ class FeedController extends AsyncNotifier { Future setTopPeriod(TopPeriodFilter period) async { final current = state.value ?? const FeedState(); - state = AsyncData(current.copyWith(topPeriodFilter: period, posts: [])); + state = AsyncData(current.copyWith( + topPeriodFilter: period, + posts: [], + hasMore: true, + emptyPageStreak: 0, + )); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( settings.copyWith(defaultTopPeriodFilter: period.name), @@ -142,7 +197,12 @@ class FeedController extends AsyncNotifier { final current = state.value ?? const FeedState(); state = AsyncData( current.copyWith( - ratingFilter: rating, clearRating: rating == null, posts: []), + ratingFilter: rating, + clearRating: rating == null, + posts: [], + hasMore: true, + emptyPageStreak: 0, + ), ); await refresh(); await saveSession(scrollOffset: 0); @@ -157,6 +217,8 @@ class FeedController extends AsyncNotifier { tagSuggestions: [], clearRating: true, posts: [], + hasMore: true, + emptyPageStreak: 0, ), ); final settings = await _settings(); @@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier { return posts; } + void _applySoftRetryResult(ProviderSoftRetryResult result) { + final current = state.value; + if (current == null || result.page != 0) return; + if (!_sameTags(current.selectedTags, result.tags)) return; + if (current.ratingFilter != result.rating) return; + if (current.topPeriodFilter != result.topPeriod) return; + if (current.selectedProviderIds.isNotEmpty && + !current.selectedProviderIds.contains(result.providerId)) { + return; + } + final seen = current.posts.map((post) => post.cacheKey).toSet(); + final newPosts = result.posts + .where((post) => seen.add(post.cacheKey)) + .toList(growable: false); + if (newPosts.isEmpty) return; + state = AsyncData( + current.copyWith( + posts: [...current.posts, ...newPosts]..sort( + (a, b) => _mixKey(a).compareTo(_mixKey(b)), + ), + providerStatusMessage: 'Retry added ${newPosts.length} posts', + ), + ); + } + + bool _sameTags(List left, List right) { + if (left.length != right.length) return false; + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) return false; + } + return true; + } + + Future _providerStatusMessage(FeedState current) async { + final result = await ref.read(providerRepositoryProvider).getDiagnostics(); + if (result is! Success>) return null; + final selected = current.selectedProviderIds.isEmpty + ? current.providers.map((provider) => provider.id).toSet() + : current.selectedProviderIds.toSet(); + final failed = result.data + .where((item) => + selected.contains(item.providerId) && + item.lastErrorMessage != null && + item.lastErrorMessage!.isNotEmpty) + .map((item) => item.providerId) + .toSet(); + if (failed.isEmpty) return null; + return 'Retrying ${failed.length} provider${failed.length == 1 ? '' : 's'}'; + } + int _mixKey(Post post) { var hash = 0x811c9dc5; for (final code in post.cacheKey.codeUnits) { diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index cf8165d..001c2a9 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -28,20 +29,24 @@ class FeedScreen extends ConsumerStatefulWidget { ConsumerState createState() => _FeedScreenState(); } -class _FeedScreenState extends ConsumerState { +class _FeedScreenState extends ConsumerState + with WidgetsBindingObserver { final _scrollController = ScrollController(); final Set _selectedKeys = {}; bool _selectionMode = false; String? _appliedInitialQuery; Timer? _scrollSaveDebounce; + double _lastKnownScrollOffset = 0; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _scrollController.addListener(() { if (_scrollController.position.extentAfter < 800) { ref.read(feedControllerProvider.notifier).loadNextPage(); } + _lastKnownScrollOffset = _scrollController.offset; _scrollSaveDebounce?.cancel(); _scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () { ref @@ -53,6 +58,7 @@ class _FeedScreenState extends ConsumerState { final settings = ref.read(appSettingsProvider).value ?? AppSettings.defaults; if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) { + _lastKnownScrollOffset = settings.lastFeedScrollOffset; _scrollController.jumpTo( settings.lastFeedScrollOffset.clamp( 0.0, @@ -80,11 +86,32 @@ class _FeedScreenState extends ConsumerState { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _scrollSaveDebounce?.cancel(); _scrollController.dispose(); super.dispose(); } + @override + void didChangeMetrics() { + if (_scrollController.hasClients) { + _lastKnownScrollOffset = _scrollController.offset; + ref + .read(feedControllerProvider.notifier) + .saveSession(scrollOffset: _lastKnownScrollOffset); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final target = _lastKnownScrollOffset.clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + if ((_scrollController.offset - target).abs() > 24) { + _scrollController.jumpTo(target); + } + }); + } + @override Widget build(BuildContext context) { final feed = ref.watch(feedControllerProvider); @@ -162,6 +189,7 @@ class _FeedScreenState extends ConsumerState { tagSuggestions: state.tagSuggestions, providers: state.providers, rating: state.ratingFilter, + providerStatusMessage: state.providerStatusMessage, onSearchChanged: (query) => ref .read(feedControllerProvider.notifier) .updateTagSuggestions(query), @@ -173,6 +201,7 @@ class _FeedScreenState extends ConsumerState { onRefresh: () => ref.read(feedControllerProvider.notifier).refresh(), onClearFilters: _clearFilters, + onRandom: () => _openRandom(state.posts), selectionMode: _selectionMode, onToggleSelectionMode: () { setState(() { @@ -228,6 +257,7 @@ class _FeedScreenState extends ConsumerState { .read(feedControllerProvider.notifier) .refresh(), child: PostMasonryGrid( + key: const PageStorageKey('feed_masonry_grid'), controller: _scrollController, posts: state.posts, columns: Responsive.columnsFor( @@ -296,6 +326,12 @@ class _FeedScreenState extends ConsumerState { context.go(location); } + void _openRandom(List posts) { + if (posts.isEmpty) return; + final post = posts[Random().nextInt(posts.length)]; + context.push('/post/${post.providerId}/${post.id}', extra: post); + } + Future _applySearchQuery(String query) async { await ref.read(feedControllerProvider.notifier).search(query); if (_scrollController.hasClients) { diff --git a/lib/features/feed/presentation/feed_state.dart b/lib/features/feed/presentation/feed_state.dart index 57a8d8b..ac3edeb 100644 --- a/lib/features/feed/presentation/feed_state.dart +++ b/lib/features/feed/presentation/feed_state.dart @@ -11,7 +11,9 @@ class FeedState { this.providers = const [], this.isLoadingMore = false, this.hasMore = true, + this.emptyPageStreak = 0, this.error, + this.providerStatusMessage, }); final List posts; @@ -23,7 +25,9 @@ class FeedState { final List providers; final bool isLoadingMore; final bool hasMore; + final int emptyPageStreak; final String? error; + final String? providerStatusMessage; FeedState copyWith({ List? posts, @@ -36,8 +40,11 @@ class FeedState { List? providers, bool? isLoadingMore, bool? hasMore, + int? emptyPageStreak, String? error, bool clearError = false, + String? providerStatusMessage, + bool clearProviderStatus = false, }) { return FeedState( posts: posts ?? this.posts, @@ -49,7 +56,11 @@ class FeedState { providers: providers ?? this.providers, isLoadingMore: isLoadingMore ?? this.isLoadingMore, hasMore: hasMore ?? this.hasMore, + emptyPageStreak: emptyPageStreak ?? this.emptyPageStreak, error: clearError ? null : error ?? this.error, + providerStatusMessage: clearProviderStatus + ? null + : providerStatusMessage ?? this.providerStatusMessage, ); } } diff --git a/lib/features/feed/presentation/widgets/feed_toolbar.dart b/lib/features/feed/presentation/widgets/feed_toolbar.dart index 97714ec..f43deb3 100644 --- a/lib/features/feed/presentation/widgets/feed_toolbar.dart +++ b/lib/features/feed/presentation/widgets/feed_toolbar.dart @@ -23,6 +23,8 @@ class FeedToolbar extends StatefulWidget { required this.onSuggestionTap, required this.onTopPeriodChanged, this.onToggleSelectionMode, + this.onRandom, + this.providerStatusMessage, this.selectionMode = false, super.key, }); @@ -43,6 +45,8 @@ class FeedToolbar extends StatefulWidget { final ValueChanged onSuggestionTap; final ValueChanged onTopPeriodChanged; final VoidCallback? onToggleSelectionMode; + final VoidCallback? onRandom; + final String? providerStatusMessage; final bool selectionMode; @override @@ -122,9 +126,24 @@ class _FeedToolbarState extends State { onPressed: widget.onRefresh, icon: const Icon(Icons.refresh_rounded), ), + IconButton.filledTonal( + tooltip: 'Random post', + onPressed: widget.onRandom, + icon: const Icon(Icons.casino_rounded), + ), ], ), const SizedBox(height: 8), + if (widget.providerStatusMessage != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: InputChip( + avatar: const Icon(Icons.sync_problem_rounded, size: 16), + label: Text(widget.providerStatusMessage!), + ), + ), + const SizedBox(height: 8), + ], _HorizontalWheelScroller( height: 36, builder: (controller) => ListView( @@ -187,6 +206,16 @@ class _FeedToolbarState extends State { }, ), const SizedBox(height: 8), + if (widget.providerStatusMessage != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: InputChip( + avatar: const Icon(Icons.sync_problem_rounded, size: 16), + label: Text(widget.providerStatusMessage!), + ), + ), + const SizedBox(height: 6), + ], _HorizontalWheelScroller( height: 40, builder: (controller) => ListView( @@ -219,6 +248,11 @@ class _FeedToolbarState extends State { onPressed: widget.onRefresh, icon: const Icon(Icons.refresh_rounded), ), + IconButton.filledTonal( + tooltip: 'Random post', + onPressed: widget.onRandom, + icon: const Icon(Icons.casino_rounded), + ), const SizedBox(width: 8), if (widget.providers.isNotEmpty) ...[ FilterChip( diff --git a/lib/features/post/presentation/post_details_controller.dart b/lib/features/post/presentation/post_details_controller.dart index bc65ba5..120c47e 100644 --- a/lib/features/post/presentation/post_details_controller.dart +++ b/lib/features/post/presentation/post_details_controller.dart @@ -3,17 +3,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; -// Меняем FutureProvider на StreamProvider +// Stream the fastest available post version first, then enrich it. final postDetailsControllerProvider = StreamProvider.family((ref, args) async* { - // Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн) final providerManager = ref.watch(providerManagerProvider); final cacheService = ref.watch(cacheServiceProvider); final postRepository = ref.watch(postRepositoryProvider); - // Вспомогательная функция для загрузки тегов + bool needsRealbooruDetails(Post post) { + if (post.providerId != 'realbooru') return false; + final preview = post.previewUrl.toLowerCase(); + final sample = post.sampleUrl.toLowerCase(); + return post.fileUrl.isEmpty || + post.fileUrl == post.previewUrl || + post.fileUrl == post.sampleUrl || + preview.contains('/thumbnails/') || + sample.contains('/thumbnails/'); + } + Future enrich(Post? post) async { if (post == null) return null; + if (needsRealbooruDetails(post)) { + final remote = await providerManager.getPost(post.providerId, post.id); + if (remote is Success && remote.data != null) { + await cacheService.cachePosts([remote.data!]); + return remote.data!; + } + } final shouldEnrich = post.tagGroups.isEmpty || (post.tagGroups.length == 1 && post.tagGroups.containsKey('general')); if (!shouldEnrich) return post; @@ -26,26 +42,29 @@ final postDetailsControllerProvider = return post; } - // СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай) + // Scenario 1: feed navigation already passed the post. if (args.initialPost != null) { - yield args - .initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится! + if (!needsRealbooruDetails(args.initialPost!)) { + yield args.initialPost; + } final enriched = await enrich(args.initialPost); - yield enriched; // Чуть позже отдаем версию с загруженными тегами + yield enriched; return; } - // СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш + // Scenario 2: direct navigation can use the local cache first. final cached = await postRepository.getCachedPost(args.postId, args.providerId); if (cached is Success && cached.data != null) { - yield cached.data; // Нашли в кэше — сразу показываем контент + if (!needsRealbooruDetails(cached.data!)) { + yield cached.data; + } final enriched = await enrich(cached.data); - yield enriched; // Догружаем теги на фоне + yield enriched; return; } - // СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом + // Scenario 3: fall back to the provider when nothing local is available. final remote = await providerManager.getPost(args.providerId, args.postId); if (remote is Success && remote.data != null) { yield remote.data; diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 3bc2e5f..d880b80 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -94,7 +94,9 @@ class PostDetailsScreen extends ConsumerWidget { return _MobilePostPager( posts: feedPosts, initialIndex: currentIndex, - buildDetails: (context, post) => _buildMobileDetails( + buildDetails: (context, post, mediaGestureLocked, + onMediaGestureLockChanged) => + _buildMobileDetails( context, ref, post, @@ -102,6 +104,8 @@ class PostDetailsScreen extends ConsumerWidget { favoriteKeys, qualityMode, strings, + mediaGestureLocked, + onMediaGestureLockChanged, ), ); } @@ -113,6 +117,8 @@ class PostDetailsScreen extends ConsumerWidget { favoriteKeys, qualityMode, strings, + false, + null, ); } return Shortcuts( @@ -186,17 +192,29 @@ class PostDetailsScreen extends ConsumerWidget { ), Expanded( child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 760), + child: SizedBox( + width: double.infinity, + height: 760, child: PostMediaViewer( key: ValueKey(post.cacheKey), post: post, qualityMode: qualityMode, + mediaHeaders: ref + .watch(postMediaHeadersProvider(post)) + .value ?? + const {}, + initialPosition: Duration( + milliseconds: settings.videoPlaybackPositions[ + post.cacheKey] ?? + 0, + ), initialLoop: settings.videoPlayerLoop, initialMuted: settings.videoPlayerMuted, initialCoverVideo: settings.videoPlayerCover, initialHalfVolume: settings.videoPlayerHalfVolume, + onPlaybackSnapshot: (snapshot) => + _saveVideoSnapshot(ref, post, snapshot), onPlaybackPreferencesChanged: (snapshot) => _saveVideoPreferences(ref, snapshot), ), @@ -233,6 +251,7 @@ class PostDetailsScreen extends ConsumerWidget { _toggleFavorite(ref, post, favoriteKeys), onCollection: () => _addToCollection(context, ref, post), onOpen: () => launchUrl(Uri.parse(post.fileUrl)), + onOpenSource: () => _openSourcePage(ref, post), onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), onSimilar: () => _openSimilar(context, ref, post), @@ -284,11 +303,14 @@ class PostDetailsScreen extends ConsumerWidget { Set favoriteKeys, MediaQualityMode qualityMode, AppStrings strings, + bool mediaGestureLocked, + ValueChanged? onMediaGestureLockChanged, ) { final isVideo = MediaUrlSelector.isVideo(post); return GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragEnd: (details) { + if (mediaGestureLocked) return; if ((details.primaryVelocity ?? 0) > 900) _close(context); }, child: ListView( @@ -302,22 +324,30 @@ class PostDetailsScreen extends ConsumerWidget { ? null : () => _showMobileQuickActions(context, ref, post, favoriteKeys), - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height * 0.62, - ), - child: Center( - child: PostMediaViewer( - key: ValueKey(post.cacheKey), - post: post, - qualityMode: qualityMode, - initialLoop: settings.videoPlayerLoop, - initialMuted: settings.videoPlayerMuted, - initialCoverVideo: settings.videoPlayerCover, - initialHalfVolume: settings.videoPlayerHalfVolume, - onPlaybackPreferencesChanged: (snapshot) => - _saveVideoPreferences(ref, snapshot), + child: SizedBox( + width: double.infinity, + height: MediaQuery.sizeOf(context).height * 0.62, + child: PostMediaViewer( + key: ValueKey(post.cacheKey), + post: post, + qualityMode: qualityMode, + mediaHeaders: + ref.watch(postMediaHeadersProvider(post)).value ?? const {}, + initialPosition: Duration( + milliseconds: + settings.videoPlaybackPositions[post.cacheKey] ?? 0, ), + initialLoop: settings.videoPlayerLoop, + initialMuted: settings.videoPlayerMuted, + initialCoverVideo: settings.videoPlayerCover, + initialHalfVolume: settings.videoPlayerHalfVolume, + onPlaybackSnapshot: (snapshot) => + _saveVideoSnapshot(ref, post, snapshot), + onPlaybackPreferencesChanged: (snapshot) => + _saveVideoPreferences(ref, snapshot), + onMediaGestureLockChanged: (locked) { + onMediaGestureLockChanged?.call(locked); + }, ), ), ), @@ -331,6 +361,7 @@ class PostDetailsScreen extends ConsumerWidget { onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), onCollection: () => _addToCollection(context, ref, post), onOpen: () => launchUrl(Uri.parse(post.fileUrl)), + onOpenSource: () => _openSourcePage(ref, post), onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), onSimilar: () => _openSimilar(context, ref, post), onHide: () => _hidePost(context, ref, post), @@ -545,6 +576,18 @@ class PostDetailsScreen extends ConsumerWidget { context.push('/post/${post.providerId}/${post.id}/similar', extra: post); } + Future _openSourcePage(WidgetRef ref, Post post) async { + final source = post.source?.trim(); + String? url = source != null && source.isNotEmpty ? source : null; + if (url == null) { + final result = + await ref.read(providerManagerProvider).getPostPageUrl(post); + if (result is Success) url = result.data; + } + if (url == null || url.isEmpty) return; + await launchUrl(Uri.parse(url)); + } + void _replacePost(BuildContext context, Post post) { context.replace('/post/${post.providerId}/${post.id}', extra: post); } @@ -580,6 +623,18 @@ class PostDetailsScreen extends ConsumerWidget { ); ref.invalidate(appSettingsProvider); } + + Future _saveVideoSnapshot( + WidgetRef ref, + Post post, + VideoPlaybackSnapshot snapshot, + ) async { + await ref.read(settingsServiceProvider).saveVideoPlaybackPosition( + post.cacheKey, + snapshot.position.inMilliseconds, + ); + ref.invalidate(appSettingsProvider); + } } class _MobilePostPager extends StatefulWidget { @@ -591,19 +646,25 @@ class _MobilePostPager extends StatefulWidget { final List posts; final int initialIndex; - final Widget Function(BuildContext context, Post post) buildDetails; + final Widget Function( + BuildContext context, + Post post, + bool mediaGestureLocked, + ValueChanged onMediaGestureLockChanged, + ) buildDetails; @override State<_MobilePostPager> createState() => _MobilePostPagerState(); } -class _MobilePostPagerState extends State<_MobilePostPager> { - late final PageController _controller; - - @override - void initState() { - super.initState(); - _controller = PageController(initialPage: widget.initialIndex); +class _MobilePostPagerState extends State<_MobilePostPager> { + late final PageController _controller; + bool _mediaGestureLocked = false; + + @override + void initState() { + super.initState(); + _controller = PageController(initialPage: widget.initialIndex); WidgetsBinding.instance.addPostFrameCallback((_) { _prefetchAround(widget.initialIndex); }); @@ -613,11 +674,11 @@ class _MobilePostPagerState extends State<_MobilePostPager> { void didUpdateWidget(covariant _MobilePostPager oldWidget) { super.didUpdateWidget(oldWidget); // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп - if (oldWidget.initialIndex != widget.initialIndex && - widget.initialIndex >= 0 && - widget.initialIndex < widget.posts.length) { - if (_controller.hasClients) { - _controller.jumpToPage(widget.initialIndex); + if (oldWidget.initialIndex != widget.initialIndex && + widget.initialIndex >= 0 && + widget.initialIndex < widget.posts.length) { + if (_controller.hasClients) { + _controller.jumpToPage(widget.initialIndex); } WidgetsBinding.instance.addPostFrameCallback((_) { _prefetchAround(widget.initialIndex); @@ -634,22 +695,50 @@ class _MobilePostPagerState extends State<_MobilePostPager> { @override Widget build(BuildContext context) { return PageView.builder( - controller: _controller, - itemCount: widget.posts.length, - onPageChanged: (index) { - _prefetchAround(index); - }, + controller: _controller, + physics: _mediaGestureLocked + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(), + itemCount: widget.posts.length, + onPageChanged: (index) { + _prefetchAround(index); + }, itemBuilder: (context, index) { - return _KeepAlivePostPage( - child: KeyedSubtree( - key: ValueKey(widget.posts[index].cacheKey), - child: widget.buildDetails(context, widget.posts[index]), - ), + final initialPost = widget.posts[index]; + return Consumer( + builder: (context, ref, _) { + final args = PostDetailsArgs( + providerId: initialPost.providerId, + postId: initialPost.id, + initialPost: initialPost, + ); + final post = ref.watch(postDetailsControllerProvider(args)); + return post.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => ErrorView(message: error.toString()), + data: (resolvedPost) => _KeepAlivePostPage( + child: KeyedSubtree( + key: ValueKey((resolvedPost ?? initialPost).cacheKey), + child: widget.buildDetails( + context, + resolvedPost ?? initialPost, + _mediaGestureLocked, + _setMediaGestureLocked, + ), + ), + ), + ); + }, ); }, ); } + void _setMediaGestureLocked(bool locked) { + if (_mediaGestureLocked == locked) return; + setState(() => _mediaGestureLocked = locked); + } + void _prefetchAround(int index) { for (final offset in [-2, -1, 0, 1, 2]) { final target = index + offset; @@ -670,19 +759,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> { } Map _headersFor(Post post) { - return { + return const { 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', 'Accept': '*/*', - if (post.providerName.toLowerCase().contains('gelbooru') || - post.fileUrl.contains('gelbooru.com') || - post.sampleUrl.contains('gelbooru.com') || - post.previewUrl.contains('gelbooru.com')) - 'Referer': 'https://gelbooru.com/', - if (post.providerName.toLowerCase().contains('rule34') || - post.fileUrl.contains('rule34') || - post.sampleUrl.contains('rule34') || - post.previewUrl.contains('rule34')) - 'Referer': 'https://rule34.xxx/', }; } } diff --git a/lib/features/post/presentation/widgets/post_action_bar.dart b/lib/features/post/presentation/widgets/post_action_bar.dart index 1e89060..f7a3acb 100644 --- a/lib/features/post/presentation/widgets/post_action_bar.dart +++ b/lib/features/post/presentation/widgets/post_action_bar.dart @@ -7,6 +7,7 @@ class PostActionBar extends StatelessWidget { required this.onOpen, required this.onCopy, required this.isFavorite, + this.onOpenSource, this.onDownload, this.onDeleteLocalFile, this.downloaded = false, @@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget { final VoidCallback onCollection; final VoidCallback onOpen; final VoidCallback onCopy; + final VoidCallback? onOpenSource; final bool isFavorite; final VoidCallback? onDownload; final VoidCallback? onDeleteLocalFile; @@ -58,6 +60,12 @@ class PostActionBar extends StatelessWidget { onPressed: onOpen, icon: const Icon(Icons.open_in_new_rounded), ), + if (onOpenSource != null) + IconButton.filledTonal( + tooltip: text.openSource, + onPressed: onOpenSource, + icon: const Icon(Icons.article_rounded), + ), IconButton.filledTonal( tooltip: text.copyLink, onPressed: onCopy, @@ -95,6 +103,7 @@ class PostActionLabels { this.collection = 'Collection', this.similar = 'Similar', this.openOriginal = 'Open original', + this.openSource = 'Open source/page', this.copyLink = 'Copy link', this.download = 'Download', this.deleteLocalFile = 'Delete local file', @@ -106,6 +115,7 @@ class PostActionLabels { final String collection; final String similar; final String openOriginal; + final String openSource; final String copyLink; final String download; final String deleteLocalFile; diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 0812480..35e44ea 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -1,1504 +1,1430 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -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 'package:webview_windows/webview_windows.dart'; - -import '../../../../app/motion.dart'; -import '../../../../backend/backend.dart'; - -final Map _playbackMemory = - {}; - -class PostMediaViewer extends StatefulWidget { - const PostMediaViewer({ - required this.post, - this.fullscreen = false, - this.initialPosition = Duration.zero, - this.autoplay = false, - this.initialLoop = false, - this.initialMuted = false, - this.initialCoverVideo = false, - this.initialHalfVolume = false, - this.qualityMode = MediaQualityMode.auto, - this.onPlaybackSnapshot, - this.onPlaybackPreferencesChanged, - super.key, - }); - - final Post post; - final bool fullscreen; - final Duration initialPosition; - final bool autoplay; - final bool initialLoop; - final bool initialMuted; - final bool initialCoverVideo; - final bool initialHalfVolume; - final MediaQualityMode qualityMode; - final ValueChanged? onPlaybackSnapshot; - final ValueChanged? onPlaybackPreferencesChanged; - - @override - State createState() => _PostMediaViewerState(); -} - -class _PostMediaViewerState extends State - with AutomaticKeepAliveClientMixin { - Player? _player; - VideoController? _controller; - late List _imageUrls; - late List _videoUrls; - int _imageIndex = 0; - int _videoIndex = 0; - bool _controlsVisible = true; - bool _inFullscreen = false; - late bool _coverVideo; - late bool _muted; - late bool _loopVideo; - late bool _halfVolume; - String? _videoError; - Timer? _hideTimer; - StreamSubscription? _errorSubscription; - StreamSubscription? _positionSubscription; - StreamSubscription? _playingSubscription; - - @override - void initState() { - super.initState(); - _coverVideo = widget.initialCoverVideo; - _muted = widget.initialMuted; - _loopVideo = widget.initialLoop; - _halfVolume = widget.initialHalfVolume; - _imageUrls = _buildImageUrls(widget.post); - _videoUrls = _buildVideoUrls(widget.post); - if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { - _initializeVideo(); - } - } - - @override - void didUpdateWidget(covariant PostMediaViewer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.post.cacheKey != widget.post.cacheKey) { - _disposeVideo(); - _imageIndex = 0; - _videoIndex = 0; - _videoError = null; - _imageUrls = _buildImageUrls(widget.post); - _videoUrls = _buildVideoUrls(widget.post); - if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { - _initializeVideo(); - } - } - } - - @override - void dispose() { - _disposeVideo(); - super.dispose(); - } - - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - if (_isSwf(widget.post)) { - return _SwfMediaViewer( - post: widget.post, - url: _swfUrl(widget.post), - fullscreen: widget.fullscreen, - ); - } - - if (_controller != null) { - if (_inFullscreen) { - return ColoredBox( - color: Colors.black, - child: AspectRatio( - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? (widget.post.width / widget.post.height).clamp(0.35, 2.4) - : 16 / 9, - child: const Center( - child: Icon(Icons.fullscreen_rounded, color: Colors.white38), - ), - ), - ); - } - return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), - }, - child: Actions( - actions: { - _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( - onInvoke: (_) { - _togglePlay(); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: _VideoSurface( - player: _player!, - controller: _controller!, - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? widget.post.width / widget.post.height - : 16 / 9, - controlsVisible: _controlsVisible, - coverVideo: _coverVideo, - muted: _muted, - loopVideo: _loopVideo, - halfVolume: _halfVolume, - fullscreen: widget.fullscreen, - errorMessage: _videoError, - onInteract: _showControls, - onRetry: _retryVideo, - onToggleFit: () { - setState(() => _coverVideo = !_coverVideo); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleMute: () async { - final nextMuted = !_muted; - setState(() => _muted = nextMuted); - await _applyVolume(); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleHalfVolume: () async { - setState(() { - _halfVolume = !_halfVolume; - if (_halfVolume) _muted = false; - }); - await _applyVolume(); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleLoop: () async { - final nextLoop = !_loopVideo; - setState(() => _loopVideo = nextLoop); - await _player!.setPlaylistMode( - nextLoop ? PlaylistMode.single : PlaylistMode.none, - ); - _emitPlaybackPreferences(); - _showControls(); - }, - onFullscreen: widget.fullscreen - ? () => Navigator.of(context, rootNavigator: true) - .pop(_snapshot()) - : () => _openFullscreen(context), - ), - ), - ), - ); - } - - final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; - final image = CachedNetworkImage( - key: ValueKey(url), - imageUrl: url, - httpHeaders: _headersFor(widget.post), - fit: BoxFit.contain, - placeholder: (context, url) => - const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) { - if (_imageIndex < _imageUrls.length - 1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _imageIndex++); - }); - return const Center(child: CircularProgressIndicator()); - } - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.broken_image_rounded, size: 48), - SizedBox(height: 8), - Text('Could not load image'), - ], - ), - ); - }, - ); - final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; - return allowPinchZoom ? _ZoomableImage(child: image) : image; - } - - void _initializeVideo() { - MediaKit.ensureInitialized(); - _player = Player(); - _controller = VideoController(_player!); - _applyVolume(); - _player!.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - _errorSubscription = _player!.stream.error.listen((message) { - if (!mounted) return; - if (_videoIndex < _videoUrls.length - 1) { - _videoIndex++; - _openVideo(play: false); - return; - } - setState(() => _videoError = message); - _showControls(); - }); - _positionSubscription = _player!.stream.position.listen((_) { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - }); - _playingSubscription = _player!.stream.playing.listen((_) { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - }); - _openVideo(play: widget.autoplay); - _scheduleControlsHide(); - } - - Future _openVideo({required bool play}) async { - final player = _player; - if (player == null || _videoUrls.isEmpty) return; - final remembered = _playbackMemory[widget.post.cacheKey]; - final initialPosition = widget.initialPosition > Duration.zero - ? widget.initialPosition - : remembered?.position ?? Duration.zero; - setState(() => _videoError = null); - await player.open( - Media( - _videoUrls[_videoIndex], - httpHeaders: _headersFor(widget.post), - ), - play: false, - ); - if (initialPosition > Duration.zero) { - await player.seek(initialPosition); - } - if (play) await player.play(); - } - - Future _retryVideo() async { - final player = _player; - if (player == null) return; - setState(() { - _videoError = null; - _videoIndex = 0; - _controlsVisible = true; - }); - await player.stop(); - await _openVideo(play: true); - _showControls(); - } - - void _disposeVideo() { - _hideTimer?.cancel(); - _hideTimer = null; - _errorSubscription?.cancel(); - _errorSubscription = null; - _positionSubscription?.cancel(); - _positionSubscription = null; - _playingSubscription?.cancel(); - _playingSubscription = null; - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - _player?.dispose(); - _player = null; - _controller = null; - } - - List _buildImageUrls(Post post) { - return MediaUrlSelector.details(post, mode: widget.qualityMode); - } - - List _buildVideoUrls(Post post) { - return MediaUrlSelector.video(post); - } - - bool _isVideo(Post post) { - final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); - return value.contains('video') || - value.contains('.webm') || - value.contains('.mp4') || - value.contains('.mov'); - } - - bool _isSwf(Post post) { - final value = - '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' - .toLowerCase(); - return value.contains('swf') || value.contains('.swf'); - } - - String _swfUrl(Post post) { - for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) { - final value = url.trim(); - if (value.toLowerCase().contains('.swf')) return value; - } - return post.fileUrl.trim(); - } - - Map _headersFor(Post post) { - return { - 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', - 'Accept': '*/*', - if (post.providerName.toLowerCase().contains('gelbooru') || - post.fileUrl.contains('gelbooru.com') || - post.sampleUrl.contains('gelbooru.com') || - post.previewUrl.contains('gelbooru.com')) - 'Referer': 'https://gelbooru.com/', - if (post.providerName.toLowerCase().contains('rule34') || - post.fileUrl.contains('rule34') || - post.sampleUrl.contains('rule34') || - post.previewUrl.contains('rule34')) - 'Referer': 'https://rule34.xxx/', - }; - } - - void _showControls() { - if (!mounted) return; - setState(() => _controlsVisible = true); - _scheduleControlsHide(); - } - - void _scheduleControlsHide() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted && _videoError == null) { - setState(() => _controlsVisible = false); - } - }); - } - - Future _openFullscreen(BuildContext context) async { - final player = _player; - final controller = _controller; - if (player == null || controller == null) return; - setState(() => _inFullscreen = true); - if (!context.mounted) return; - final result = await Navigator.of(context, rootNavigator: true) - .push( - PageRouteBuilder( - opaque: true, - pageBuilder: (_, __, ___) => _FullscreenVideoPage( - player: player, - controller: controller, - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? widget.post.width / widget.post.height - : 16 / 9, - loopVideo: _loopVideo, - muted: _muted, - halfVolume: _halfVolume, - coverVideo: _coverVideo, - errorMessage: _videoError, - onRetry: _retryVideo, - onChanged: (snapshot) { - unawaited(_applyPlaybackSnapshot(snapshot)); - }, - ), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), - ), - ); - if (mounted) setState(() => _inFullscreen = false); - if (result != null) { - await _applyPlaybackSnapshot(result); - return; - } - } - - Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async { - if (!mounted) return; - setState(() { - _muted = snapshot.muted; - _loopVideo = snapshot.loopVideo; - _coverVideo = snapshot.coverVideo; - _halfVolume = snapshot.halfVolume; - }); - await _applyVolume(); - await _player?.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - _emitPlaybackPreferences(); - } - - VideoPlaybackSnapshot _snapshot() { - final player = _player; - return VideoPlaybackSnapshot( - position: player?.state.position ?? Duration.zero, - playing: player?.state.playing ?? false, - muted: _muted, - halfVolume: _halfVolume, - loopVideo: _loopVideo, - coverVideo: _coverVideo, - ); - } - - Future _applyVolume() async { - await _player?.setVolume(_muted - ? 0 - : _halfVolume - ? 50 - : 100); - } - - void _emitPlaybackPreferences() { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - widget.onPlaybackPreferencesChanged?.call(snapshot); - } - - Future _togglePlay() async { - final player = _player; - if (player == null) return; - if (player.state.playing) { - await player.pause(); - } else { - await player.play(); - } - _showControls(); - } -} - -class _TogglePlayIntent extends Intent { - const _TogglePlayIntent(); -} - -class _SwfMediaViewer extends StatelessWidget { - const _SwfMediaViewer({ - required this.post, - required this.url, - required this.fullscreen, - }); - - final Post post; - final String url; - final bool fullscreen; - - @override - Widget build(BuildContext context) { - if (url.isEmpty) { - return const Center(child: Text('SWF URL is empty')); - } - if (Platform.isWindows) { - return _WindowsRuffleViewer(url: url, fullscreen: fullscreen); - } - return _SwfUnsupportedPanel(url: url); - } -} - -class _WindowsRuffleViewer extends StatefulWidget { - const _WindowsRuffleViewer({ - required this.url, - required this.fullscreen, - }); - - final String url; - final bool fullscreen; - - @override - State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState(); -} - -class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> { - final _controller = WebviewController(); - bool _loading = true; - String? _error; - - @override - void initState() { - super.initState(); - _initialize(); - } - - @override - void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.url != widget.url) { - _load(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final child = ClipRRect( - borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), - child: Stack( - fit: StackFit.expand, - children: [ - if (_controller.value.isInitialized) Webview(_controller), - if (_loading) - const ColoredBox( - color: Colors.black, - child: Center(child: CircularProgressIndicator()), - ), - if (_error != null) - _SwfErrorPanel( - message: _error!, - url: widget.url, - onRetry: _load, - ), - ], - ), - ); - if (widget.fullscreen) return child; - return AspectRatio(aspectRatio: 4 / 3, child: child); - } - - Future _initialize() async { - try { - await _controller.initialize(); - _controller.loadingState.listen((state) { - if (!mounted) return; - setState(() => _loading = state == LoadingState.loading); - }); - _controller.onLoadError.listen((error) { - if (!mounted) return; - setState(() => _error = 'Ruffle WebView error: $error'); - }); - await _load(); - } catch (error) { - if (!mounted) return; - setState(() { - _loading = false; - _error = - 'Could not start Windows WebView2. Install WebView2 Runtime or open original.'; - }); - } - } - - Future _load() async { - if (!_controller.value.isInitialized) return; - setState(() { - _loading = true; - _error = null; - }); - try { - await _controller.loadStringContent(_ruffleHtml(widget.url)); - } catch (error) { - if (!mounted) return; - setState(() { - _loading = false; - _error = 'Could not load Ruffle viewer: $error'; - }); - } - } - - String _ruffleHtml(String swfUrl) { - final escapedUrl = _htmlEscape(swfUrl); - return ''' - - - - - - - - - - -
- -
- - -'''; - } - - String _htmlEscape(String value) { - return value - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll('<', '<') - .replaceAll('>', '>'); - } -} - -class _SwfUnsupportedPanel extends StatelessWidget { - const _SwfUnsupportedPanel({required this.url}); - - final String url; - - @override - Widget build(BuildContext context) { - return _SwfErrorPanel( - message: Platform.isAndroid - ? 'Flash/SWF playback is desktop only.' - : 'SWF playback is available on Windows only in this build.', - url: url, - ); - } -} - -class _SwfErrorPanel extends StatelessWidget { - const _SwfErrorPanel({ - required this.message, - required this.url, - this.onRetry, - }); - - final String message; - final String url; - final VoidCallback? onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.all(22), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.extension_rounded, - color: Colors.white, size: 48), - const SizedBox(height: 12), - Text( - 'SWF / Flash', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.white), - ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - if (onRetry != null) - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - FilledButton.tonalIcon( - onPressed: () => launchUrl( - Uri.parse(url), - mode: LaunchMode.externalApplication, - ), - icon: const Icon(Icons.open_in_new_rounded), - label: const Text('Open original'), - ), - FilledButton.tonalIcon( - style: FilledButton.styleFrom( - backgroundColor: - scheme.primaryContainer.withValues(alpha: 0.82), - ), - onPressed: () => launchUrl( - Uri.parse( - 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}', - ), - mode: LaunchMode.externalApplication, - ), - icon: const Icon(Icons.play_circle_outline_rounded), - label: const Text('Open in Ruffle'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class VideoPlaybackSnapshot { - const VideoPlaybackSnapshot({ - required this.position, - required this.playing, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.coverVideo, - }); - - final Duration position; - final bool playing; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool coverVideo; -} - -class _FullscreenVideoPage extends StatefulWidget { - const _FullscreenVideoPage({ - required this.player, - required this.controller, - required this.aspectRatio, - required this.loopVideo, - required this.muted, - required this.halfVolume, - required this.coverVideo, - required this.errorMessage, - required this.onRetry, - required this.onChanged, - }); - - final Player player; - final VideoController controller; - final double aspectRatio; - final bool loopVideo; - final bool muted; - final bool halfVolume; - final bool coverVideo; - final String? errorMessage; - final VoidCallback onRetry; - final ValueChanged onChanged; - - @override - State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); -} - -class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { - bool _controlsVisible = true; - late bool _coverVideo; - late bool _muted; - late bool _loopVideo; - late bool _halfVolume; - Timer? _hideTimer; - - @override - void initState() { - super.initState(); - _coverVideo = widget.coverVideo; - _muted = widget.muted; - _loopVideo = widget.loopVideo; - _halfVolume = widget.halfVolume; - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - DeviceOrientation.portraitUp, - ]); - _scheduleControlsHide(); - } - - @override - void dispose() { - _hideTimer?.cancel(); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - SystemChrome.setPreferredOrientations(DeviceOrientation.values); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - Navigator.of(context, rootNavigator: true).pop(_snapshot()); - }, - child: Scaffold( - backgroundColor: Colors.black, - body: Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), - LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), - }, - child: Actions( - actions: { - _CloseVideoIntent: CallbackAction<_CloseVideoIntent>( - onInvoke: (_) { - Navigator.of(context, rootNavigator: true).pop(_snapshot()); - return null; - }, - ), - _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( - onInvoke: (_) { - widget.player.playOrPause(); - _showControls(); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: Stack( - children: [ - Positioned.fill( - child: _VideoSurface( - player: widget.player, - controller: widget.controller, - aspectRatio: widget.aspectRatio, - controlsVisible: _controlsVisible, - coverVideo: _coverVideo, - muted: _muted, - loopVideo: _loopVideo, - halfVolume: _halfVolume, - fullscreen: true, - errorMessage: widget.errorMessage, - onInteract: _showControls, - onRetry: widget.onRetry, - onToggleFit: () { - setState(() => _coverVideo = !_coverVideo); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleMute: () async { - setState(() => _muted = !_muted); - await _applyVolume(); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleHalfVolume: () async { - setState(() { - _halfVolume = !_halfVolume; - if (_halfVolume) _muted = false; - }); - await _applyVolume(); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleLoop: () async { - setState(() => _loopVideo = !_loopVideo); - await widget.player.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - widget.onChanged(_snapshot()); - _showControls(); - }, - onFullscreen: () => Navigator.of( - context, - rootNavigator: true, - ).pop(_snapshot()), - ), - ), - Positioned( - top: 12, - right: 12, - child: SafeArea( - child: IconButton.filledTonal( - tooltip: 'Close fullscreen', - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(_snapshot()), - icon: const Icon(Icons.close_rounded), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - void _showControls() { - if (!mounted) return; - setState(() => _controlsVisible = true); - _scheduleControlsHide(); - } - - void _scheduleControlsHide() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted && widget.errorMessage == null) { - setState(() => _controlsVisible = false); - } - }); - } - - Future _applyVolume() async { - await widget.player.setVolume(_muted - ? 0 - : _halfVolume - ? 50 - : 100); - } - - VideoPlaybackSnapshot _snapshot() { - return VideoPlaybackSnapshot( - position: widget.player.state.position, - playing: widget.player.state.playing, - muted: _muted, - halfVolume: _halfVolume, - loopVideo: _loopVideo, - coverVideo: _coverVideo, - ); - } -} - -class _CloseVideoIntent extends Intent { - const _CloseVideoIntent(); -} - -class _VideoSurface extends StatelessWidget { - const _VideoSurface({ - required this.player, - required this.controller, - required this.aspectRatio, - required this.controlsVisible, - required this.coverVideo, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.fullscreen, - required this.errorMessage, - required this.onInteract, - required this.onRetry, - required this.onToggleFit, - required this.onToggleMute, - required this.onToggleHalfVolume, - required this.onToggleLoop, - required this.onFullscreen, - }); - - final Player player; - final VideoController controller; - final double aspectRatio; - final bool controlsVisible; - final bool coverVideo; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool fullscreen; - final String? errorMessage; - final VoidCallback onInteract; - final VoidCallback onRetry; - final VoidCallback onToggleFit; - final VoidCallback onToggleMute; - final VoidCallback onToggleHalfVolume; - final VoidCallback onToggleLoop; - final VoidCallback onFullscreen; - - @override - Widget build(BuildContext context) { - final child = Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: Colors.black, - child: Video( - controller: controller, - fit: coverVideo ? BoxFit.cover : BoxFit.contain, - controls: null, - ), - ), - StreamBuilder( - stream: player.stream.buffering, - initialData: player.state.buffering, - builder: (context, snapshot) { - if (snapshot.data != true || errorMessage != null) { - return const SizedBox.shrink(); - } - return const Center(child: CircularProgressIndicator()); - }, - ), - if (errorMessage != null) - VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), - if (errorMessage == null) - StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, snapshot) { - final playing = snapshot.data ?? false; - return AnimatedScale( - scale: controlsVisible || !playing ? 1 : 0.85, - duration: AppMotion.duration(context, 160), - child: AnimatedOpacity( - opacity: controlsVisible || !playing ? 1 : 0, - duration: AppMotion.duration(context, 160), - child: Center( - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.46), - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Colors.black45, - blurRadius: 24, - offset: Offset(0, 10), - ), - ], - ), - child: IconButton( - tooltip: playing ? 'Pause' : 'Play', - iconSize: fullscreen ? 58 : 44, - color: Colors.white, - onPressed: () { - player.playOrPause(); - onInteract(); - }, - icon: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - ), - ), - ), - ), - ); - }, - ), - IgnorePointer( - ignoring: !controlsVisible || errorMessage != null, - child: AnimatedOpacity( - opacity: controlsVisible || errorMessage != null ? 1 : 0, - duration: AppMotion.duration(context, 180), - child: _VideoControls( - player: player, - muted: muted, - halfVolume: halfVolume, - loopVideo: loopVideo, - coverVideo: coverVideo, - fullscreen: fullscreen, - onToggleFit: onToggleFit, - onToggleMute: onToggleMute, - onToggleHalfVolume: onToggleHalfVolume, - onToggleLoop: onToggleLoop, - onFullscreen: onFullscreen, - ), - ), - ), - ], - ); - - return MouseRegion( - onHover: (_) => onInteract(), - child: GestureDetector( - onTap: onInteract, - onLongPress: player.playOrPause, - onDoubleTap: onFullscreen, - child: ClipRRect( - borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), - child: fullscreen - ? SizedBox.expand(child: child) - : AspectRatio( - aspectRatio: aspectRatio.clamp(0.35, 2.4), - child: child, - ), - ), - ), - ); - } -} - -class VideoErrorOverlay extends StatelessWidget { - const VideoErrorOverlay({ - required this.message, - required this.onRetry, - super.key, - }); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return ColoredBox( - color: Colors.black.withValues(alpha: 0.62), - child: Center( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline_rounded, - color: Colors.white, size: 42), - const SizedBox(height: 10), - Text( - 'Could not load video', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.white), - ), - const SizedBox(height: 6), - Text( - message, - maxLines: 3, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 14), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } -} - -class _ZoomableImage extends StatefulWidget { - const _ZoomableImage({required this.child}); - - final Widget child; - - @override - State<_ZoomableImage> createState() => _ZoomableImageState(); -} - -class _ZoomableImageState extends State<_ZoomableImage> { - final _controller = TransformationController(); - TapDownDetails? _doubleTapDetails; - bool _zoomed = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onDoubleTapDown: (details) => _doubleTapDetails = details, - onDoubleTap: _toggleZoom, - child: InteractiveViewer( - transformationController: _controller, - minScale: 1, - maxScale: 5, - panEnabled: _zoomed, - scaleEnabled: true, - clipBehavior: Clip.none, - onInteractionEnd: (_) { - final scale = _controller.value.getMaxScaleOnAxis(); - if (mounted) setState(() => _zoomed = scale > 1.03); - }, - child: widget.child, - ), - ); - } - - void _toggleZoom() { - final tap = _doubleTapDetails?.localPosition ?? Offset.zero; - if (_zoomed) { - _controller.value = Matrix4.identity(); - setState(() => _zoomed = false); - return; - } - _controller.value = Matrix4.identity() - ..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1) - ..scaleByDouble(2.3, 2.3, 1, 1); - setState(() => _zoomed = true); - } -} - -class _RoundControlButton extends StatelessWidget { - const _RoundControlButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - this.emphasized = false, - this.selected = false, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - final bool emphasized; - final bool selected; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = emphasized - ? scheme.primary - : selected - ? scheme.primaryContainer.withValues(alpha: 0.96) - : Colors.white.withValues(alpha: 0.13); - final foreground = emphasized - ? scheme.onPrimary - : selected - ? scheme.onPrimaryContainer - : Colors.white; - return Tooltip( - message: tooltip, - child: InkResponse( - onTap: onPressed, - radius: emphasized ? 23 : 21, - child: AnimatedContainer( - duration: AppMotion.duration(context, 140), - width: emphasized ? 38 : 34, - height: emphasized ? 38 : 34, - decoration: BoxDecoration( - color: background, - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), - ), - ), - ); - } -} - -class _VideoControls extends StatelessWidget { - const _VideoControls({ - required this.player, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.coverVideo, - required this.fullscreen, - required this.onToggleFit, - required this.onToggleMute, - required this.onToggleHalfVolume, - required this.onToggleLoop, - required this.onFullscreen, - }); - - final Player player; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool coverVideo; - final bool fullscreen; - final VoidCallback onToggleFit; - final VoidCallback onToggleMute; - final VoidCallback onToggleHalfVolume; - final VoidCallback onToggleLoop; - final VoidCallback onFullscreen; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Align( - alignment: Alignment.bottomCenter, - child: SafeArea( - minimum: EdgeInsets.fromLTRB( - fullscreen ? 14 : 8, - 0, - fullscreen ? 14 : 8, - fullscreen ? 10 : 8, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.58), - borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, playingSnapshot) { - final playing = playingSnapshot.data ?? false; - return StreamBuilder( - stream: player.stream.duration, - initialData: player.state.duration, - builder: (context, durationSnapshot) { - final duration = durationSnapshot.data ?? Duration.zero; - return StreamBuilder( - stream: player.stream.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final maxMs = duration.inMilliseconds - .clamp(1, 1 << 31) - .toDouble(); - final valueMs = position.inMilliseconds - .clamp(0, maxMs.toInt()) - .toDouble(); - return LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 560; - final tiny = constraints.maxWidth < 390; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: scheme.primary, - inactiveTrackColor: - Colors.white.withValues(alpha: 0.24), - trackHeight: 3, - thumbColor: scheme.primary, - overlayColor: - scheme.primary.withValues(alpha: 0.18), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - ), - ), - child: Slider( - value: valueMs, - max: maxMs, - onChanged: duration == Duration.zero - ? null - : (value) => player.seek( - Duration( - milliseconds: value.round(), - ), - ), - ), - ), - Row( - children: [ - _RoundControlButton( - tooltip: playing ? 'Pause' : 'Play', - emphasized: true, - onPressed: player.playOrPause, - icon: playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - const SizedBox(width: 8), - Text( - tiny - ? _format(position) - : '${_format(position)} / ${_format(duration)}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - if (!compact) ...[ - _RoundControlButton( - tooltip: 'Back 10s', - onPressed: () => player.seek( - _clampSeek( - position - - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.replay_10_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: 'Forward 10s', - onPressed: () => player.seek( - _clampSeek( - position + - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.forward_10_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: halfVolume - ? 'Normal app volume' - : 'Half app volume', - selected: halfVolume, - onPressed: onToggleHalfVolume, - icon: Icons.volume_down_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: loopVideo - ? 'Disable repeat' - : 'Repeat video', - selected: loopVideo, - onPressed: onToggleLoop, - icon: loopVideo - ? Icons.repeat_one_on_rounded - : Icons.repeat_one_rounded, - ), - const SizedBox(width: 5), - if (!compact) ...[ - _RoundControlButton( - tooltip: coverVideo ? 'Fit' : 'Fill', - selected: coverVideo, - onPressed: onToggleFit, - icon: coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: fullscreen - ? 'Exit fullscreen' - : 'Fullscreen', - onPressed: onFullscreen, - icon: fullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - ), - ], - ), - ], - ); - }, - ); - }, - ); - }, - ); - }, - ), - ), - ), - ), - ); - } - - String _format(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - final hours = duration.inHours; - if (hours > 0) return '$hours:$minutes:$seconds'; - return '$minutes:$seconds'; - } - - Duration _clampSeek(Duration position, Duration duration) { - if (position < Duration.zero) return Duration.zero; - if (duration > Duration.zero && position > duration) return duration; - return position; - } -} +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; +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 '../../../../app/motion.dart'; +import '../../../../backend/backend.dart'; + +final Map _playbackMemory = + {}; + +class PostMediaViewer extends StatefulWidget { + const PostMediaViewer({ + required this.post, + this.fullscreen = false, + this.initialPosition = Duration.zero, + this.autoplay = false, + this.initialLoop = false, + this.initialMuted = false, + this.initialCoverVideo = false, + this.initialHalfVolume = false, + this.qualityMode = MediaQualityMode.auto, + this.mediaHeaders = const {}, + this.onPlaybackSnapshot, + this.onPlaybackPreferencesChanged, + this.onMediaGestureLockChanged, + super.key, + }); + + final Post post; + final bool fullscreen; + final Duration initialPosition; + final bool autoplay; + final bool initialLoop; + final bool initialMuted; + final bool initialCoverVideo; + final bool initialHalfVolume; + final MediaQualityMode qualityMode; + final Map mediaHeaders; + final ValueChanged? onPlaybackSnapshot; + final ValueChanged? onPlaybackPreferencesChanged; + final ValueChanged? onMediaGestureLockChanged; + + @override + State createState() => _PostMediaViewerState(); +} + +class _PostMediaViewerState extends State + with AutomaticKeepAliveClientMixin { + Player? _player; + VideoController? _controller; + late List _imageUrls; + late List _videoUrls; + int _imageIndex = 0; + int _videoIndex = 0; + bool _controlsVisible = true; + bool _inFullscreen = false; + late bool _coverVideo; + late bool _muted; + late bool _loopVideo; + late bool _halfVolume; + String? _videoError; + Timer? _hideTimer; + StreamSubscription? _errorSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playingSubscription; + DateTime? _lastSnapshotEmitAt; + + @override + void initState() { + super.initState(); + _coverVideo = widget.initialCoverVideo; + _muted = widget.initialMuted; + _loopVideo = widget.initialLoop; + _halfVolume = widget.initialHalfVolume; + _imageUrls = _buildImageUrls(widget.post); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } + } + + @override + void didUpdateWidget(covariant PostMediaViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.cacheKey != widget.post.cacheKey) { + _disposeVideo(); + _imageIndex = 0; + _videoIndex = 0; + _videoError = null; + _imageUrls = _buildImageUrls(widget.post); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } + } + } + + @override + void dispose() { + _disposeVideo(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + if (_isSwf(widget.post)) { + return const _UnsupportedSwfPanel(); + } + + if (_controller != null) { + if (_inFullscreen) { + return ColoredBox( + color: Colors.black, + child: AspectRatio( + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? (widget.post.width / widget.post.height).clamp(0.35, 2.4) + : 16 / 9, + child: const Center( + child: Icon(Icons.fullscreen_rounded, color: Colors.white38), + ), + ), + ); + } + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + }, + child: Actions( + actions: { + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + _togglePlay(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: _VideoSurface( + player: _player!, + controller: _controller!, + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? widget.post.width / widget.post.height + : 16 / 9, + controlsVisible: _controlsVisible, + coverVideo: _coverVideo, + muted: _muted, + loopVideo: _loopVideo, + halfVolume: _halfVolume, + fullscreen: widget.fullscreen, + errorMessage: _videoError, + onInteract: _showControls, + onRetry: _retryVideo, + onToggleFit: () { + setState(() => _coverVideo = !_coverVideo); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleMute: () async { + final nextMuted = !_muted; + setState(() => _muted = nextMuted); + await _applyVolume(); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleHalfVolume: () async { + setState(() { + _halfVolume = !_halfVolume; + if (_halfVolume) _muted = false; + }); + await _applyVolume(); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleLoop: () async { + final nextLoop = !_loopVideo; + setState(() => _loopVideo = nextLoop); + await _player!.setPlaylistMode( + nextLoop ? PlaylistMode.single : PlaylistMode.none, + ); + _emitPlaybackPreferences(); + _showControls(); + }, + onFullscreen: widget.fullscreen + ? () => Navigator.of(context, rootNavigator: true) + .pop(_snapshot()) + : () => _openFullscreen(context), + ), + ), + ), + ); + } + + final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; + final headers = _headersFor(widget.post); + final image = CachedNetworkImage( + key: ValueKey(url), + imageUrl: url, + httpHeaders: headers, + fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), + errorWidget: (context, url, error) { + return _DioImageFallback( + imageUrl: url, + headers: headers, + fit: BoxFit.contain, + onFailed: _advanceImageFallback, + ); + }, + ); + final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; + final child = allowPinchZoom + ? _ZoomableImage( + onGestureLockChanged: widget.onMediaGestureLockChanged, + child: image, + ) + : image; + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: child, + ); + } + return child; + }, + ); + } + + void _initializeVideo() { + MediaKit.ensureInitialized(); + _player = Player(); + _controller = VideoController(_player!); + _applyVolume(); + _player!.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + _errorSubscription = _player!.stream.error.listen((message) { + if (!mounted) return; + unawaited(_handleVideoError(message)); + }); + _positionSubscription = _player!.stream.position.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + _emitPlaybackSnapshotThrottled(snapshot); + }); + _playingSubscription = _player!.stream.playing.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + }); + _openVideo(play: widget.autoplay); + _scheduleControlsHide(); + } + + Future _openVideo({required bool play}) async { + final player = _player; + if (player == null || _videoUrls.isEmpty) return; + final remembered = _playbackMemory[widget.post.cacheKey]; + final initialPosition = widget.initialPosition > Duration.zero + ? widget.initialPosition + : remembered?.position ?? Duration.zero; + final shouldPlay = play || (remembered?.playing ?? false); + try { + setState(() => _videoError = null); + await player.stop(); + await player.open( + Media( + _videoUrls[_videoIndex], + httpHeaders: _headersFor(widget.post), + ), + play: false, + ); + if (initialPosition > Duration.zero) { + await player.seek(initialPosition); + } + if (shouldPlay) await player.play(); + } catch (error) { + await _handleVideoError(error.toString(), play: shouldPlay); + } + } + + Future _handleVideoError(String message, {bool play = false}) async { + if (_videoIndex < _videoUrls.length - 1) { + _videoIndex++; + await _openVideo(play: play); + return; + } + if (!mounted) return; + setState(() => _videoError = message); + _showControls(); + } + + Future _retryVideo() async { + final player = _player; + if (player == null) return; + setState(() { + _videoError = null; + _videoIndex = 0; + _controlsVisible = true; + }); + await player.stop(); + await _openVideo(play: true); + _showControls(); + } + + void _disposeVideo() { + _hideTimer?.cancel(); + _hideTimer = null; + _errorSubscription?.cancel(); + _errorSubscription = null; + _positionSubscription?.cancel(); + _positionSubscription = null; + _playingSubscription?.cancel(); + _playingSubscription = null; + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + _player?.dispose(); + _player = null; + _controller = null; + } + + List _buildImageUrls(Post post) { + return MediaUrlSelector.details(post, mode: widget.qualityMode); + } + + List _buildVideoUrls(Post post) { + return MediaUrlSelector.video(post); + } + + bool _isVideo(Post post) { + final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); + return value.contains('video') || + value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } + + bool _isSwf(Post post) { + final value = + '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' + .toLowerCase(); + return value.contains('swf') || value.contains('.swf'); + } + + Map _headersFor(Post post) { + return { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + ...widget.mediaHeaders, + }; + } + + void _advanceImageFallback() { + if (_imageIndex >= _imageUrls.length - 1) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _imageIndex++); + }); + } + + void _showControls() { + if (!mounted) return; + setState(() => _controlsVisible = true); + _scheduleControlsHide(); + } + + void _scheduleControlsHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && _videoError == null) { + setState(() => _controlsVisible = false); + } + }); + } + + Future _openFullscreen(BuildContext context) async { + final player = _player; + final controller = _controller; + if (player == null || controller == null) return; + setState(() => _inFullscreen = true); + if (!context.mounted) return; + final result = await Navigator.of(context, rootNavigator: true) + .push( + PageRouteBuilder( + opaque: true, + pageBuilder: (_, __, ___) => _FullscreenVideoPage( + player: player, + controller: controller, + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? widget.post.width / widget.post.height + : 16 / 9, + loopVideo: _loopVideo, + muted: _muted, + halfVolume: _halfVolume, + coverVideo: _coverVideo, + errorMessage: _videoError, + onRetry: _retryVideo, + onChanged: (snapshot) { + unawaited(_applyPlaybackSnapshot(snapshot)); + }, + ), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + if (mounted) setState(() => _inFullscreen = false); + if (result != null) { + await _applyPlaybackSnapshot(result); + return; + } + } + + Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async { + if (!mounted) return; + setState(() { + _muted = snapshot.muted; + _loopVideo = snapshot.loopVideo; + _coverVideo = snapshot.coverVideo; + _halfVolume = snapshot.halfVolume; + }); + await _applyVolume(); + await _player?.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + _emitPlaybackPreferences(); + } + + VideoPlaybackSnapshot _snapshot() { + final player = _player; + return VideoPlaybackSnapshot( + position: player?.state.position ?? Duration.zero, + playing: player?.state.playing ?? false, + muted: _muted, + halfVolume: _halfVolume, + loopVideo: _loopVideo, + coverVideo: _coverVideo, + ); + } + + Future _applyVolume() async { + await _player?.setVolume(_muted + ? 0 + : _halfVolume + ? 50 + : 100); + } + + void _emitPlaybackPreferences() { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackPreferencesChanged?.call(snapshot); + } + + void _emitPlaybackSnapshotThrottled(VideoPlaybackSnapshot snapshot) { + final now = DateTime.now(); + final last = _lastSnapshotEmitAt; + if (last != null && now.difference(last) < const Duration(seconds: 5)) { + return; + } + _lastSnapshotEmitAt = now; + widget.onPlaybackSnapshot?.call(snapshot); + } + + Future _togglePlay() async { + final player = _player; + if (player == null) return; + if (player.state.playing) { + await player.pause(); + } else { + await player.play(); + } + _showControls(); + } +} + +class _TogglePlayIntent extends Intent { + const _TogglePlayIntent(); +} + +class _UnsupportedSwfPanel extends StatelessWidget { + const _UnsupportedSwfPanel(); + + @override + Widget build(BuildContext context) { + return const ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.extension_off_rounded, color: Colors.white, size: 48), + SizedBox(height: 12), + Text( + 'SWF / Flash is not supported in this build', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} + +class VideoPlaybackSnapshot { + const VideoPlaybackSnapshot({ + required this.position, + required this.playing, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.coverVideo, + }); + + final Duration position; + final bool playing; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool coverVideo; +} + +class _FullscreenVideoPage extends StatefulWidget { + const _FullscreenVideoPage({ + required this.player, + required this.controller, + required this.aspectRatio, + required this.loopVideo, + required this.muted, + required this.halfVolume, + required this.coverVideo, + required this.errorMessage, + required this.onRetry, + required this.onChanged, + }); + + final Player player; + final VideoController controller; + final double aspectRatio; + final bool loopVideo; + final bool muted; + final bool halfVolume; + final bool coverVideo; + final String? errorMessage; + final VoidCallback onRetry; + final ValueChanged onChanged; + + @override + State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); +} + +class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { + bool _controlsVisible = true; + late bool _coverVideo; + late bool _muted; + late bool _loopVideo; + late bool _halfVolume; + Timer? _hideTimer; + + @override + void initState() { + super.initState(); + _coverVideo = widget.coverVideo; + _muted = widget.muted; + _loopVideo = widget.loopVideo; + _halfVolume = widget.halfVolume; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + ]); + _scheduleControlsHide(); + } + + @override + void dispose() { + _hideTimer?.cancel(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + Navigator.of(context, rootNavigator: true).pop(_snapshot()); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + }, + child: Actions( + actions: { + _CloseVideoIntent: CallbackAction<_CloseVideoIntent>( + onInvoke: (_) { + Navigator.of(context, rootNavigator: true).pop(_snapshot()); + return null; + }, + ), + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + widget.player.playOrPause(); + _showControls(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: Stack( + children: [ + Positioned.fill( + child: _VideoSurface( + player: widget.player, + controller: widget.controller, + aspectRatio: widget.aspectRatio, + controlsVisible: _controlsVisible, + coverVideo: _coverVideo, + muted: _muted, + loopVideo: _loopVideo, + halfVolume: _halfVolume, + fullscreen: true, + errorMessage: widget.errorMessage, + onInteract: _showControls, + onRetry: widget.onRetry, + onToggleFit: () { + setState(() => _coverVideo = !_coverVideo); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleMute: () async { + setState(() => _muted = !_muted); + await _applyVolume(); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleHalfVolume: () async { + setState(() { + _halfVolume = !_halfVolume; + if (_halfVolume) _muted = false; + }); + await _applyVolume(); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleLoop: () async { + setState(() => _loopVideo = !_loopVideo); + await widget.player.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + widget.onChanged(_snapshot()); + _showControls(); + }, + onFullscreen: () => Navigator.of( + context, + rootNavigator: true, + ).pop(_snapshot()), + ), + ), + Positioned( + top: 12, + right: 12, + child: SafeArea( + child: IconButton.filledTonal( + tooltip: 'Close fullscreen', + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(_snapshot()), + icon: const Icon(Icons.close_rounded), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _showControls() { + if (!mounted) return; + setState(() => _controlsVisible = true); + _scheduleControlsHide(); + } + + void _scheduleControlsHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && widget.errorMessage == null) { + setState(() => _controlsVisible = false); + } + }); + } + + Future _applyVolume() async { + await widget.player.setVolume(_muted + ? 0 + : _halfVolume + ? 50 + : 100); + } + + VideoPlaybackSnapshot _snapshot() { + return VideoPlaybackSnapshot( + position: widget.player.state.position, + playing: widget.player.state.playing, + muted: _muted, + halfVolume: _halfVolume, + loopVideo: _loopVideo, + coverVideo: _coverVideo, + ); + } +} + +class _CloseVideoIntent extends Intent { + const _CloseVideoIntent(); +} + +class _VideoSurface extends StatelessWidget { + const _VideoSurface({ + required this.player, + required this.controller, + required this.aspectRatio, + required this.controlsVisible, + required this.coverVideo, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.fullscreen, + required this.errorMessage, + required this.onInteract, + required this.onRetry, + required this.onToggleFit, + required this.onToggleMute, + required this.onToggleHalfVolume, + required this.onToggleLoop, + required this.onFullscreen, + }); + + final Player player; + final VideoController controller; + final double aspectRatio; + final bool controlsVisible; + final bool coverVideo; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool fullscreen; + final String? errorMessage; + final VoidCallback onInteract; + final VoidCallback onRetry; + final VoidCallback onToggleFit; + final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; + final VoidCallback onToggleLoop; + final VoidCallback onFullscreen; + + @override + Widget build(BuildContext context) { + final child = Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: Colors.black, + child: Video( + controller: controller, + fit: coverVideo ? BoxFit.cover : BoxFit.contain, + controls: null, + ), + ), + StreamBuilder( + stream: player.stream.buffering, + initialData: player.state.buffering, + builder: (context, snapshot) { + if (snapshot.data != true || errorMessage != null) { + return const SizedBox.shrink(); + } + return const Center(child: CircularProgressIndicator()); + }, + ), + if (errorMessage != null) + VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), + IgnorePointer( + ignoring: !controlsVisible || errorMessage != null, + child: AnimatedOpacity( + opacity: controlsVisible || errorMessage != null ? 1 : 0, + duration: AppMotion.duration(context, 180), + child: _VideoControls( + player: player, + muted: muted, + halfVolume: halfVolume, + loopVideo: loopVideo, + coverVideo: coverVideo, + fullscreen: fullscreen, + onToggleFit: onToggleFit, + onToggleMute: onToggleMute, + onToggleHalfVolume: onToggleHalfVolume, + onToggleLoop: onToggleLoop, + onFullscreen: onFullscreen, + ), + ), + ), + if (errorMessage == null) + StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return AnimatedScale( + scale: controlsVisible || !playing ? 1 : 0.85, + duration: AppMotion.duration(context, 160), + child: AnimatedOpacity( + opacity: controlsVisible || !playing ? 1 : 0, + duration: AppMotion.duration(context, 160), + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.46), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black45, + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: IconButton( + tooltip: playing ? 'Pause' : 'Play', + iconSize: fullscreen ? 58 : 44, + color: Colors.white, + onPressed: () { + player.playOrPause(); + onInteract(); + }, + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ), + ), + ), + ); + }, + ), + ], + ); + + return MouseRegion( + onHover: (_) => onInteract(), + child: GestureDetector( + onTap: onInteract, + onLongPress: player.playOrPause, + onDoubleTap: onFullscreen, + child: ClipRRect( + borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), + child: fullscreen + ? SizedBox.expand(child: child) + : AspectRatio( + aspectRatio: aspectRatio.clamp(0.35, 2.4), + child: child, + ), + ), + ), + ); + } +} + +class VideoErrorOverlay extends StatelessWidget { + const VideoErrorOverlay({ + required this.message, + required this.onRetry, + super.key, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black.withValues(alpha: 0.62), + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, + color: Colors.white, size: 42), + const SizedBox(height: 10), + Text( + 'Could not load video', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white), + ), + const SizedBox(height: 6), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} + +class _DioImageFallback extends StatefulWidget { + const _DioImageFallback({ + required this.imageUrl, + required this.headers, + required this.fit, + required this.onFailed, + }); + + final String imageUrl; + final Map headers; + final BoxFit fit; + final VoidCallback onFailed; + + @override + State<_DioImageFallback> createState() => _DioImageFallbackState(); +} + +class _DioImageFallbackState extends State<_DioImageFallback> { + late final Future _bytes = _load(); + bool _reportedFailure = false; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _bytes, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final bytes = snapshot.data; + if (snapshot.hasError || bytes == null || bytes.isEmpty) { + _reportFailure(); + return const _ImageLoadError(); + } + return Image.memory( + bytes, + fit: widget.fit, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + _reportFailure(); + return const _ImageLoadError(); + }, + ); + }, + ); + } + + Future _load() async { + final response = await Dio().get>( + widget.imageUrl, + options: Options( + responseType: ResponseType.bytes, + headers: widget.headers, + followRedirects: true, + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 10), + ), + ); + final data = response.data; + if (response.statusCode == null || + response.statusCode! < 200 || + response.statusCode! >= 300 || + data == null || + data.isEmpty) { + throw StateError('Could not load image'); + } + return Uint8List.fromList(data); + } + + void _reportFailure() { + if (_reportedFailure) return; + _reportedFailure = true; + widget.onFailed(); + } +} + +class _ImageLoadError extends StatelessWidget { + const _ImageLoadError(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_rounded, size: 48), + SizedBox(height: 8), + Text('Could not load image'), + ], + ), + ); + } +} + +class _ZoomableImage extends StatefulWidget { + const _ZoomableImage({ + required this.child, + this.onGestureLockChanged, + }); + + final Widget child; + final ValueChanged? onGestureLockChanged; + + @override + State<_ZoomableImage> createState() => _ZoomableImageState(); +} + +class _ZoomableImageState extends State<_ZoomableImage> { + final _controller = TransformationController(); + TapDownDetails? _doubleTapDetails; + bool _zoomed = false; + int _pointerCount = 0; + bool _locked = false; + + @override + void dispose() { + _setLocked(false); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final hasFrame = + constraints.hasBoundedWidth && constraints.hasBoundedHeight; + final child = hasFrame + ? SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: widget.child, + ) + : widget.child; + return Listener( + onPointerDown: (_) { + _pointerCount++; + if (_pointerCount >= 2 || _zoomed) { + _setLocked(true); + } + }, + onPointerUp: (_) => _releasePointer(), + onPointerCancel: (_) => _releasePointer(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTapDown: (details) => _doubleTapDetails = details, + onDoubleTap: _toggleZoom, + child: InteractiveViewer( + transformationController: _controller, + minScale: 1, + maxScale: 6, + boundaryMargin: const EdgeInsets.all(160), + panEnabled: _zoomed, + scaleEnabled: true, + clipBehavior: Clip.none, + onInteractionStart: (_) { + if (_pointerCount >= 2 || _zoomed) _setLocked(true); + }, + onInteractionEnd: (_) { + final scale = _controller.value.getMaxScaleOnAxis(); + final nextZoomed = scale > 1.03; + if (!nextZoomed) { + _controller.value = Matrix4.identity(); + } + if (mounted) setState(() => _zoomed = nextZoomed); + _setLocked(nextZoomed || _pointerCount >= 2); + }, + child: child, + ), + ), + ); + }, + ); + } + + void _toggleZoom() { + final tap = _doubleTapDetails?.localPosition ?? Offset.zero; + if (_zoomed) { + _controller.value = Matrix4.identity(); + setState(() => _zoomed = false); + _setLocked(false); + return; + } + const scale = 2.5; + _controller.value = Matrix4.identity() + ..translateByDouble(-tap.dx * (scale - 1), -tap.dy * (scale - 1), 0, 1) + ..scaleByDouble(scale, scale, 1, 1); + setState(() => _zoomed = true); + _setLocked(true); + } + + void _releasePointer() { + if (_pointerCount > 0) _pointerCount--; + if (_pointerCount == 0 && !_zoomed) _setLocked(false); + } + + void _setLocked(bool value) { + if (_locked == value) return; + _locked = value; + widget.onGestureLockChanged?.call(value); + } +} + +class _RoundControlButton extends StatelessWidget { + const _RoundControlButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + this.emphasized = false, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool emphasized; + final bool selected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final background = emphasized + ? scheme.primary + : selected + ? scheme.primaryContainer.withValues(alpha: 0.96) + : Colors.white.withValues(alpha: 0.13); + final foreground = emphasized + ? scheme.onPrimary + : selected + ? scheme.onPrimaryContainer + : Colors.white; + return Tooltip( + message: tooltip, + child: InkResponse( + onTap: onPressed, + radius: emphasized ? 23 : 21, + child: AnimatedContainer( + duration: AppMotion.duration(context, 140), + width: emphasized ? 38 : 34, + height: emphasized ? 38 : 34, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), + ), + ), + ); + } +} + +class _VideoControls extends StatelessWidget { + const _VideoControls({ + required this.player, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.coverVideo, + required this.fullscreen, + required this.onToggleFit, + required this.onToggleMute, + required this.onToggleHalfVolume, + required this.onToggleLoop, + required this.onFullscreen, + }); + + final Player player; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool coverVideo; + final bool fullscreen; + final VoidCallback onToggleFit; + final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; + final VoidCallback onToggleLoop; + final VoidCallback onFullscreen; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + minimum: EdgeInsets.fromLTRB( + fullscreen ? 14 : 8, + 0, + fullscreen ? 14 : 8, + fullscreen ? 10 : 8, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, playingSnapshot) { + final playing = playingSnapshot.data ?? false; + return StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final duration = durationSnapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final maxMs = duration.inMilliseconds + .clamp(1, 1 << 31) + .toDouble(); + final valueMs = position.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(); + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + final tiny = constraints.maxWidth < 390; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: scheme.primary, + inactiveTrackColor: + Colors.white.withValues(alpha: 0.24), + trackHeight: 3, + thumbColor: scheme.primary, + overlayColor: + scheme.primary.withValues(alpha: 0.18), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), + ), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: value.round(), + ), + ), + ), + ), + Row( + children: [ + _RoundControlButton( + tooltip: playing ? 'Pause' : 'Play', + emphasized: true, + onPressed: player.playOrPause, + icon: playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + const SizedBox(width: 8), + Text( + tiny + ? _format(position) + : '${_format(position)} / ${_format(duration)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (!compact) ...[ + _RoundControlButton( + tooltip: 'Back 10s', + onPressed: () => player.seek( + _clampSeek( + position - + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.replay_10_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: 'Forward 10s', + onPressed: () => player.seek( + _clampSeek( + position + + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.forward_10_rounded, + ), + const SizedBox(width: 5), + ], + _RoundControlButton( + tooltip: muted ? 'Unmute' : 'Mute', + onPressed: onToggleMute, + icon: muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: halfVolume + ? 'Normal app volume' + : 'Half app volume', + selected: halfVolume, + onPressed: onToggleHalfVolume, + icon: Icons.volume_down_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: loopVideo + ? 'Disable repeat' + : 'Repeat video', + selected: loopVideo, + onPressed: onToggleLoop, + icon: loopVideo + ? Icons.repeat_one_on_rounded + : Icons.repeat_one_rounded, + ), + const SizedBox(width: 5), + if (!compact) ...[ + _RoundControlButton( + tooltip: coverVideo ? 'Fit' : 'Fill', + selected: coverVideo, + onPressed: onToggleFit, + icon: coverVideo + ? Icons.fit_screen_rounded + : Icons.crop_free_rounded, + ), + const SizedBox(width: 5), + ], + _RoundControlButton( + tooltip: fullscreen + ? 'Exit fullscreen' + : 'Fullscreen', + onPressed: onFullscreen, + icon: fullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + ), + ), + ); + } + + String _format(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + final hours = duration.inHours; + if (hours > 0) return '$hours:$minutes:$seconds'; + return '$minutes:$seconds'; + } + + Duration _clampSeek(Duration position, Duration duration) { + if (position < Duration.zero) return Duration.zero; + if (duration > Duration.zero && position > duration) return duration; + return position; + } +} diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart index 440b55e..85492f2 100644 --- a/lib/features/providers/presentation/provider_form_screen.dart +++ b/lib/features/providers/presentation/provider_form_screen.dart @@ -89,7 +89,9 @@ class _ProviderFormScreenState extends ConsumerState { DropdownMenuItem( value: 'rule34', child: Text('rule34-compatible')), DropdownMenuItem( - value: 'realbooru', child: Text('realbooru-compatible')), + value: 'paheal', child: Text('Rule34 Paheal HTML')), + DropdownMenuItem( + value: 'realbooru_html', child: Text('realbooru HTML')), DropdownMenuItem( value: 'danbooru', child: Text('danbooru-compatible')), DropdownMenuItem( diff --git a/lib/features/search/presentation/search_controller.dart b/lib/features/search/presentation/search_controller.dart index 8715ef0..5fa718d 100644 --- a/lib/features/search/presentation/search_controller.dart +++ b/lib/features/search/presentation/search_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../backend/backend.dart'; @@ -8,8 +10,12 @@ final searchControllerProvider = AsyncNotifierProvider(SearchController.new); class SearchController extends AsyncNotifier { + Timer? _suggestionDebounce; + int _suggestionRequestId = 0; + @override Future build() async { + ref.onDispose(() => _suggestionDebounce?.cancel()); final recent = await _recent(); return SearchState(recent: recent); } @@ -17,18 +23,42 @@ class SearchController extends AsyncNotifier { Future updateQuery(String query) async { final lastToken = query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; - final suggestionsResult = await ref - .read(searchServiceProvider) - .autocompleteDetailed(lastToken, limit: 16); - final suggestions = suggestionsResult is Success> - ? suggestionsResult.data - : []; + final requestId = ++_suggestionRequestId; + _suggestionDebounce?.cancel(); + if (lastToken.isEmpty) { + state = AsyncData( + (state.value ?? const SearchState()).copyWith( + query: query, + suggestions: const [], + ), + ); + return; + } state = AsyncData( (state.value ?? const SearchState()).copyWith( query: query, - suggestions: suggestions, ), ); + _suggestionDebounce = Timer(const Duration(milliseconds: 280), () async { + final suggestionsResult = await ref + .read(searchServiceProvider) + .autocompleteDetailed(lastToken, limit: 16); + if (requestId != _suggestionRequestId) return; + final normalizedToken = lastToken.trim().toLowerCase(); + final suggestions = suggestionsResult is Success> + ? suggestionsResult.data + .where((item) => item.name.toLowerCase().startsWith( + normalizedToken, + )) + .toList(growable: false) + : []; + state = AsyncData( + (state.value ?? const SearchState()).copyWith( + query: query, + suggestions: suggestions, + ), + ); + }); } Future clearHistory() async { diff --git a/lib/features/search/presentation/widgets/recent_searches.dart b/lib/features/search/presentation/widgets/recent_searches.dart index afc159c..d3f7208 100644 --- a/lib/features/search/presentation/widgets/recent_searches.dart +++ b/lib/features/search/presentation/widgets/recent_searches.dart @@ -2,24 +2,45 @@ import 'package:flutter/material.dart'; import '../../../../backend/backend.dart'; -class RecentSearches extends StatelessWidget { +class RecentSearches extends StatefulWidget { const RecentSearches({required this.items, required this.onTap, super.key}); final List items; final ValueChanged onTap; + @override + State createState() => _RecentSearchesState(); +} + +class _RecentSearchesState extends State { + bool _expanded = false; + @override Widget build(BuildContext context) { - if (items.isEmpty) return const Text('No recent searches'); + if (widget.items.isEmpty) return const Text('No recent searches'); + final visibleItems = + _expanded ? widget.items : widget.items.take(30).toList(); return Column( children: [ - for (final item in items) + for (final item in visibleItems) ListTile( leading: const Icon(Icons.history_rounded), title: Text(item.query), subtitle: - Text('${item.tags.join(' ')} · ${item.resultCount} results'), - onTap: () => onTap(item.query), + Text('${item.tags.join(' ')} - ${item.resultCount} results'), + onTap: () => widget.onTap(item.query), + ), + if (widget.items.length > 30) + TextButton.icon( + onPressed: () => setState(() => _expanded = !_expanded), + icon: Icon( + _expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded, + ), + label: Text( + _expanded + ? 'Show latest 30' + : 'Show all ${widget.items.length} searches', + ), ), ], ); diff --git a/lib/features/settings/presentation/hidden_posts_screen.dart b/lib/features/settings/presentation/hidden_posts_screen.dart new file mode 100644 index 0000000..9e4365b --- /dev/null +++ b/lib/features/settings/presentation/hidden_posts_screen.dart @@ -0,0 +1,130 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/app.dart'; +import '../../../backend/backend.dart'; +import '../../../core/utils/result.dart'; +import '../../../shared/widgets/adaptive_scaffold.dart'; +import 'settings_controller.dart'; + +final hiddenPostRowsProvider = FutureProvider>((ref) async { + final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final repository = ref.watch(postRepositoryProvider); + final rows = []; + for (final key in settings.hiddenPostKeys) { + final parts = key.split(':'); + Post? post; + if (parts.length >= 2) { + final result = await repository.getCachedPost( + parts.sublist(1).join(':'), parts.first); + if (result is Success) post = result.data; + } + rows.add(HiddenPostRow(cacheKey: key, post: post)); + } + return rows; +}); + +class HiddenPostsScreen extends ConsumerWidget { + const HiddenPostsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rows = ref.watch(hiddenPostRowsProvider); + return AdaptiveScaffold( + title: 'Hidden posts', + actions: [ + IconButton( + tooltip: 'Restore all', + onPressed: rows.valueOrNull?.isEmpty ?? true + ? null + : () async { + await ref.read(settingsServiceProvider).clearHiddenPosts(); + ref.invalidate(appSettingsProvider); + ref.invalidate(settingsControllerProvider); + ref.invalidate(hiddenPostRowsProvider); + }, + icon: const Icon(Icons.restore_rounded), + ), + ], + body: rows.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text(error.toString())), + data: (items) => items.isEmpty + ? const Center(child: Text('No hidden posts')) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _HiddenPostTile( + item: item, + onOpen: item.post == null + ? null + : () => context.push( + '/post/${item.post!.providerId}/${item.post!.id}', + extra: item.post, + ), + onRestore: () async { + await ref + .read(settingsServiceProvider) + .unhidePostKey(item.cacheKey); + ref.invalidate(appSettingsProvider); + ref.invalidate(settingsControllerProvider); + ref.invalidate(hiddenPostRowsProvider); + }, + ); + }, + ), + ), + ); + } +} + +class HiddenPostRow { + const HiddenPostRow({required this.cacheKey, this.post}); + + final String cacheKey; + final Post? post; +} + +class _HiddenPostTile extends StatelessWidget { + const _HiddenPostTile({ + required this.item, + required this.onRestore, + this.onOpen, + }); + + final HiddenPostRow item; + final VoidCallback onRestore; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final post = item.post; + final imageUrl = + post == null ? null : MediaUrlSelector.preview(post).firstOrNull; + return Card( + child: ListTile( + onTap: onOpen, + leading: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 56, + height: 56, + child: imageUrl == null + ? const Icon(Icons.visibility_off_rounded) + : CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.cover), + ), + ), + title: Text(post?.tags.take(4).join(' ') ?? item.cacheKey), + subtitle: Text(post?.providerName ?? item.cacheKey), + trailing: TextButton( + onPressed: onRestore, + child: const Text('Restore'), + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index a23317e..439e023 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -101,8 +101,12 @@ class _SettingsContent extends ConsumerWidget { ), SwitchListTile( value: settings.autoDownloadFavorites, - title: Text(strings.autoDownloadFavorites), - subtitle: Text(strings.autoDownloadFavoritesHint), + title: Text(settings.languageCode == 'ru' + ? 'Offline избранное' + : 'Offline favorites'), + subtitle: Text(settings.languageCode == 'ru' + ? 'При добавлении фото или видео в избранное файл скачивается в фоне для просмотра офлайн.' + : 'When a photo or video is added to Favorites, Lunaris downloads it in the background for offline viewing.'), onChanged: (value) => _update( ref, settings.copyWith(autoDownloadFavorites: value), @@ -283,6 +287,13 @@ class _SettingsContent extends ConsumerWidget { icon: const Icon(Icons.history_toggle_off_rounded), label: Text(strings.clearViewed), ), + FilledButton.tonalIcon( + onPressed: settings.hiddenPostKeys.isEmpty + ? null + : () => context.go('/settings/hidden'), + icon: const Icon(Icons.restore_rounded), + label: const Text('Restore hidden'), + ), FilledButton.tonalIcon( onPressed: () => ref .read(settingsControllerProvider.notifier) @@ -741,13 +752,19 @@ class _ColorSwatches extends StatelessWidget { final int selected; final ValueChanged onChanged; - static const colors = [ - 0xFFE84D8A, - 0xFF8B5CF6, - 0xFF3B82F6, - 0xFF14B8A6, - 0xFFF97316, - 0xFFEF4444, + static const colors = <(int, String)>[ + (0xFFE84D8A, 'Lunaris pink'), + (0xFF8B5CF6, 'Violet'), + (0xFF3B82F6, 'Azure'), + (0xFF0EA5E9, 'Sky'), + (0xFF14B8A6, 'Teal'), + (0xFF22C55E, 'Green'), + (0xFFEAB308, 'Amber'), + (0xFFF97316, 'Orange'), + (0xFFEF4444, 'Red'), + (0xFFEC4899, 'Hot pink'), + (0xFF6366F1, 'Indigo'), + (0xFF64748B, 'Slate'), ]; @override @@ -761,28 +778,28 @@ class _ColorSwatches extends StatelessWidget { spacing: 10, runSpacing: 10, children: [ - for (final value in colors) + for (final entry in colors) Tooltip( message: - '#${value.toRadixString(16).substring(2).toUpperCase()}', + '${entry.$2} #${entry.$1.toRadixString(16).substring(2).toUpperCase()}', child: InkWell( borderRadius: BorderRadius.circular(999), - onTap: () => onChanged(value), + onTap: () => onChanged(entry.$1), child: DecoratedBox( decoration: BoxDecoration( - color: Color(value), + color: Color(entry.$1), shape: BoxShape.circle, border: Border.all( - color: selected == value + color: selected == entry.$1 ? Theme.of(context).colorScheme.onSurface : Theme.of(context).colorScheme.outlineVariant, - width: selected == value ? 3 : 1, + width: selected == entry.$1 ? 3 : 1, ), ), child: SizedBox( width: 38, height: 38, - child: selected == value + child: selected == entry.$1 ? const Icon(Icons.check_rounded, color: Colors.white) : null, ), diff --git a/lib/features/viewed/presentation/viewed_controller.dart b/lib/features/viewed/presentation/viewed_controller.dart index 1f1ef71..08be59d 100644 --- a/lib/features/viewed/presentation/viewed_controller.dart +++ b/lib/features/viewed/presentation/viewed_controller.dart @@ -4,11 +4,13 @@ import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; final viewedControllerProvider = - AsyncNotifierProvider>(ViewedController.new); + AsyncNotifierProvider>( + ViewedController.new, +); -class ViewedController extends AsyncNotifier> { +class ViewedController extends AsyncNotifier> { @override - Future> build() => _load(); + Future> build() => _load(); Future refresh() async { state = AsyncData(await _load()); @@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier> { state = const AsyncData([]); } - Future> _load() async { + Future> _load() async { final result = - await ref.read(viewedHistoryServiceProvider).getViewedPosts(); - return result is Success> ? result.data : const []; + await ref.read(viewedHistoryServiceProvider).getViewedPostEntries(); + if (result is! Success>) return const []; + return groupViewedTimeline(result.data); } } + +class ViewedTimelineGroup { + const ViewedTimelineGroup({required this.label, required this.items}); + + final String label; + final List items; +} + +List groupViewedTimeline( + List entries, { + DateTime? now, +}) { + final today = DateTime(now?.year ?? DateTime.now().year, + now?.month ?? DateTime.now().month, now?.day ?? DateTime.now().day); + final buckets = >{}; + for (final entry in entries) { + final date = + DateTime(entry.viewedAt.year, entry.viewedAt.month, entry.viewedAt.day); + final days = today.difference(date).inDays; + final label = switch (days) { + 0 => 'Today', + 1 => 'Yesterday', + _ => _dateLabel(date), + }; + buckets.putIfAbsent(label, () => []).add(entry); + } + return [ + for (final entry in buckets.entries) + ViewedTimelineGroup(label: entry.key, items: entry.value), + ]; +} + +String _dateLabel(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; diff --git a/lib/features/viewed/presentation/viewed_screen.dart b/lib/features/viewed/presentation/viewed_screen.dart index 8751a57..df4a39e 100644 --- a/lib/features/viewed/presentation/viewed_screen.dart +++ b/lib/features/viewed/presentation/viewed_screen.dart @@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; -import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; -import '../../../shared/widgets/post_masonry_grid.dart'; import '../../favorites/presentation/favorites_controller.dart'; import 'viewed_controller.dart'; @@ -21,7 +19,6 @@ class ViewedScreen extends ConsumerWidget { final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; - final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; return AdaptiveScaffold( title: settings.languageCode == 'ru' ? 'История' : 'Viewed', actions: [ @@ -34,47 +31,111 @@ class ViewedScreen extends ConsumerWidget { body: state.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorView(message: error.toString()), - data: (posts) => posts.isEmpty + data: (groups) => groups.isEmpty ? const EmptyView(title: 'No viewed posts yet') : RefreshIndicator( onRefresh: () => ref.read(viewedControllerProvider.notifier).refresh(), - child: PostMasonryGrid( - posts: posts, - columns: Responsive.columnsFor( - context, - mobileColumns: settings.mobileColumns, - desktopColumns: settings.desktopColumns, - ), - blurExplicit: settings.blurExplicitContent, - showBadges: settings.showPostBadges, - nsfwEnabled: settings.nsfwEnabled, - mediaQualityMode: - MediaQualityMode.fromName(settings.mediaQualityMode), - favoriteKeys: favoriteKeys, - viewedKeys: viewedKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: post, - ), - onFavorite: (post) async { - if (favoriteKeys.contains(post.cacheKey)) { - await ref - .read(favoriteServiceProvider) - .removeFavorite(post.id, post.providerId); - } else { - await ref.read(favoriteServiceProvider).addFavorite( - post, - settings: settings, - downloadManager: - ref.read(downloadManagerServiceProvider), - ); - } - ref.invalidate(favoriteKeysProvider); - }, + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + for (final group in groups) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Text( + group.label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + for (final item in group.items) + _ViewedPostTile( + post: item.post, + viewedAt: item.viewedAt, + isFavorite: favoriteKeys.contains(item.post.cacheKey), + onOpen: () => context.push( + '/post/${item.post.providerId}/${item.post.id}', + extra: item.post, + ), + onFavorite: () => _toggleFavorite( + ref, + item.post, + settings, + favoriteKeys, + ), + ), + ], + ], ), ), ), ); } + + Future _toggleFavorite( + WidgetRef ref, + Post post, + AppSettings settings, + Set favoriteKeys, + ) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite( + post, + settings: settings, + downloadManager: ref.read(downloadManagerServiceProvider), + ); + } + ref.invalidate(favoriteKeysProvider); + } +} + +class _ViewedPostTile extends StatelessWidget { + const _ViewedPostTile({ + required this.post, + required this.viewedAt, + required this.isFavorite, + required this.onOpen, + required this.onFavorite, + }); + + final Post post; + final DateTime viewedAt; + final bool isFavorite; + final VoidCallback onOpen; + final VoidCallback onFavorite; + + @override + Widget build(BuildContext context) { + final image = MediaUrlSelector.preview(post).firstOrNull; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + onTap: onOpen, + leading: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 56, + height: 56, + child: image == null + ? const Icon(Icons.image_not_supported_rounded) + : Image.network(image, fit: BoxFit.cover), + ), + ), + title: Text(post.tags.take(4).join(' '), maxLines: 1), + subtitle: Text( + '${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}', + ), + trailing: IconButton( + tooltip: isFavorite ? 'Remove favorite' : 'Favorite', + onPressed: onFavorite, + icon: Icon(isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded), + ), + ), + ); + } } diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index bed5478..80af18e 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -54,6 +54,8 @@ class _TagInputSearchBarState extends State { OverlayEntry? _suggestionsOverlay; Timer? _debounce; List _tags = []; + String _lastExternalValue = ''; + bool _localDirty = false; @override void initState() { @@ -62,15 +64,20 @@ class _TagInputSearchBarState extends State { _focusNode = FocusNode(); _tagScrollController = ScrollController(); _focusNode.addListener(_handleFocusChanged); - _setFromQuery(widget.initialValue ?? ''); + _lastExternalValue = widget.initialValue ?? ''; + _setFromQuery(_lastExternalValue); } @override void didUpdateWidget(covariant TagInputSearchBar oldWidget) { super.didUpdateWidget(oldWidget); final next = widget.initialValue ?? ''; - if (!_focusNode.hasFocus && oldWidget.initialValue != next) { + final externalChanged = next != _lastExternalValue; + final externalCleared = next.isEmpty && _lastExternalValue.isNotEmpty; + if (externalChanged && (externalCleared || !_isEditing)) { _setFromQuery(next); + _lastExternalValue = next; + _localDirty = false; } if (oldWidget.suggestions != widget.suggestions) { _syncSuggestionsOverlay(); @@ -90,6 +97,13 @@ class _TagInputSearchBarState extends State { void _handleFocusChanged() { if (!mounted) return; + if (!_focusNode.hasFocus && !_localDirty) { + final next = widget.initialValue ?? ''; + if (next != _lastExternalValue) { + _setFromQuery(next); + _lastExternalValue = next; + } + } setState(() {}); _syncSuggestionsOverlay(); } @@ -193,7 +207,11 @@ class _TagInputSearchBarState extends State { } void _syncSuggestionsOverlay() { - if (!_focusNode.hasFocus || widget.suggestions.isEmpty) { + final token = _activeToken; + final suggestions = widget.suggestions + .where((item) => item.name.toLowerCase().startsWith(token)) + .toList(growable: false); + if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) { _removeSuggestionsOverlay(); return; } @@ -203,20 +221,17 @@ class _TagInputSearchBarState extends State { if (overlay == null || renderBox == null || !renderBox.hasSize) return; final size = renderBox.size; - if (_suggestionsOverlay == null) { - _suggestionsOverlay = OverlayEntry( - builder: (context) => _TagSuggestionOverlay( - link: _layerLink, - width: size.width, - yOffset: size.height + 8, - suggestions: widget.suggestions, - onSelected: _applySuggestion, - ), - ); - overlay.insert(_suggestionsOverlay!); - return; - } - _suggestionsOverlay?.markNeedsBuild(); + _removeSuggestionsOverlay(); + _suggestionsOverlay = OverlayEntry( + builder: (context) => _TagSuggestionOverlay( + link: _layerLink, + width: size.width, + yOffset: size.height + 8, + suggestions: suggestions, + onSelected: _applySuggestion, + ), + ); + overlay.insert(_suggestionsOverlay!); } void _removeSuggestionsOverlay() { @@ -268,6 +283,7 @@ class _TagInputSearchBarState extends State { } void _handleDraftChanged(String value) { + _localDirty = true; final composing = _controller.value.composing; if (!composing.isValid && value.isNotEmpty && @@ -286,6 +302,7 @@ class _TagInputSearchBarState extends State { void _commitDraft(String value) { final additions = _parseTags(value); if (additions.isEmpty) return; + _localDirty = true; setState(() { for (final tag in additions) { if (!_tags.contains(tag)) _tags.add(tag); @@ -299,11 +316,14 @@ class _TagInputSearchBarState extends State { _debounce?.cancel(); _commitDraft(_controller.text); _removeSuggestionsOverlay(); + _lastExternalValue = _query; + _localDirty = false; widget.onSubmitted(_query); } void _clear() { _debounce?.cancel(); + _localDirty = true; setState(() { _tags = []; _controller.clear(); @@ -313,6 +333,7 @@ class _TagInputSearchBarState extends State { } void _editTag(String tag) { + _localDirty = true; setState(() { _tags = _tags.where((item) => item != tag).toList(growable: true); _controller.value = TextEditingValue( @@ -325,6 +346,7 @@ class _TagInputSearchBarState extends State { } void _removeTag(String tag) { + _localDirty = true; setState(() { _tags = _tags.where((item) => item != tag).toList(growable: true); }); @@ -333,6 +355,7 @@ class _TagInputSearchBarState extends State { void _applySuggestion(String suggestion) { final draft = _controller.text.trim(); + _localDirty = true; setState(() { if (draft.isNotEmpty) { final draftTags = _parseTags(draft); @@ -353,7 +376,9 @@ class _TagInputSearchBarState extends State { } void _notifyChanged() { + _localDirty = true; widget.onChanged?.call(_query); + _syncSuggestionsOverlay(); } List _parseTags(String query) { @@ -372,6 +397,14 @@ class _TagInputSearchBarState extends State { final all = [..._tags, if (draft.isNotEmpty) draft]; return all.join(' '); } + + String get _activeToken { + final draft = _controller.text.trim().toLowerCase(); + if (draft.isEmpty) return ''; + return draft.split(RegExp(r'\s+')).last; + } + + bool get _isEditing => _focusNode.hasFocus || _localDirty; } class _TagSuggestionOverlay extends StatelessWidget { @@ -396,11 +429,14 @@ class _TagSuggestionOverlay extends StatelessWidget { link: link, showWhenUnlinked: false, offset: Offset(0, yOffset), - child: SizedBox( - width: width.clamp(280, 620).toDouble(), - child: _TagSuggestionDropdown( - suggestions: suggestions, - onSelected: onSelected, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width.clamp(280, 620).toDouble(), + child: _TagSuggestionDropdown( + suggestions: suggestions, + onSelected: onSelected, + ), ), ), ), @@ -420,62 +456,59 @@ class _TagSuggestionDropdown extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 620), - child: Material( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.18), - color: scheme.surface, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 6), - itemCount: suggestions.length > 8 ? 8 : suggestions.length, - separatorBuilder: (_, __) => Divider( - height: 1, - indent: 52, - color: scheme.outlineVariant.withValues(alpha: 0.55), - ), - itemBuilder: (context, index) { - final suggestion = suggestions[index]; - return InkWell( - onTap: () => onSelected(suggestion.name), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - Icon(Icons.tag_rounded, color: scheme.primary, size: 20), - const SizedBox(width: 14), - Expanded( - child: Text( - suggestion.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - const SizedBox(width: 10), - _TagSuggestionBadge(suggestion.categoryLabel), - if (suggestion.postCount > 0) ...[ - const SizedBox(width: 8), - Text( - _compactCount(suggestion.postCount), - style: - Theme.of(context).textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - ); - }, + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: Material( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.18), + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: suggestions.length > 8 ? 8 : suggestions.length, + separatorBuilder: (_, __) => Divider( + height: 1, + indent: 52, + color: scheme.outlineVariant.withValues(alpha: 0.55), ), + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return InkWell( + onTap: () => onSelected(suggestion.name), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Icon(Icons.tag_rounded, color: scheme.primary, size: 20), + const SizedBox(width: 14), + Expanded( + child: Text( + suggestion.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox(width: 10), + _TagSuggestionBadge(suggestion.categoryLabel), + if (suggestion.postCount > 0) ...[ + const SizedBox(width: 8), + Text( + _compactCount(suggestion.postCount), + style: + Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ); + }, ), ), ); diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 2c1660a..bbce41c 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -287,7 +287,9 @@ class _MobileShell extends StatelessWidget { ? null : FloatingActionButton.small( heroTag: 'mobile-settings', - tooltip: ru ? 'Настройки' : 'Settings', + tooltip: ru + ? '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438' + : 'Settings', onPressed: () => context.go('/settings'), child: const Icon(Icons.settings_rounded), ), @@ -325,14 +327,15 @@ class _Destination { String labelFor(bool ru) { if (!ru) return label; return switch (id) { - 'feed' => 'Лента', - 'search' => 'Поиск', - 'favorites' => 'Избранное', - 'viewed' => 'История', - 'collections' => 'Коллекции', - 'artists' => 'Артисты', - 'providers' => 'Провайдеры', - 'settings' => 'Настройки', + 'feed' => '\u041b\u0435\u043d\u0442\u0430', + 'search' => '\u041f\u043e\u0438\u0441\u043a', + 'favorites' => '\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435', + 'viewed' => '\u0418\u0441\u0442\u043e\u0440\u0438\u044f', + 'collections' => '\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438', + 'artists' => '\u0410\u0440\u0442\u0438\u0441\u0442\u044b', + 'providers' => + '\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b', + 'settings' => '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438', _ => label, }; } @@ -340,8 +343,8 @@ class _Destination { String mobileLabelFor(bool ru) { if (!ru) return mobileLabel; return switch (id) { - 'collections' => 'Доски', - 'favorites' => 'Любимое', + 'collections' => '\u0414\u043e\u0441\u043a\u0438', + 'favorites' => '\u041b\u044e\u0431\u0438\u043c\u043e\u0435', _ => labelFor(true), }; } diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 9838871..af02e37 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -1,13 +1,20 @@ +import 'dart:async'; +import 'dart:collection'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; import '../../app/motion.dart'; import '../../backend/backend.dart'; +import '../../core/utils/result.dart'; import 'blur_content.dart'; import 'loading_skeleton.dart'; import 'rating_badge.dart'; -class PostCard extends StatefulWidget { +class PostCard extends ConsumerStatefulWidget { const PostCard({ required this.post, required this.blurExplicit, @@ -44,25 +51,40 @@ class PostCard extends StatefulWidget { final bool selected; @override - State createState() => _PostCardState(); + ConsumerState createState() => _PostCardState(); } -class _PostCardState extends State { +class _PostCardState extends ConsumerState { + static final Map _resolvedRealbooruPosts = {}; + bool _hovered = false; + Post? _resolvedPost; + + @override + void initState() { + super.initState(); + _resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey]; + _maybeResolveRealbooruPost(); + } + + @override + void didUpdateWidget(covariant PostCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.cacheKey != widget.post.cacheKey) { + _resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey]; + _maybeResolveRealbooruPost(); + } + } @override Widget build(BuildContext context) { - final post = widget.post; + final post = _resolvedPost ?? widget.post; final sensitive = _isSensitive(post.rating); final aspect = post.width > 0 && post.height > 0 ? post.width / post.height : _fallbackAspect(post.fileType); final mobile = MediaQuery.sizeOf(context).width < 700; - final urls = MediaUrlSelector.feed( - post, - mode: widget.mediaQualityMode, - mobile: mobile, - ); + final urls = _feedUrls(post, mobile: mobile); final imageUrl = urls.isEmpty ? post.previewUrl : urls.first; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -130,8 +152,21 @@ class _PostCardState extends State { final dpr = MediaQuery.devicePixelRatioOf(context); final cacheWidth = (constraints.maxWidth * dpr).round().clamp(320, 1800); + final headers = _headersFor(post); + final videoUrls = MediaUrlSelector.video(post); + if (_resolvedPost != null && + MediaUrlSelector.isVideo(post) && + videoUrls.isNotEmpty) { + return _FeedVideoPreview( + videoUrl: videoUrls.first, + headers: headers, + fallbackImageUrl: imageUrl, + cacheWidth: cacheWidth, + ); + } return CachedNetworkImage( imageUrl: imageUrl, + httpHeaders: headers, memCacheWidth: cacheWidth, maxWidthDiskCache: cacheWidth, fit: BoxFit.cover, @@ -291,6 +326,236 @@ class _PostCardState extends State { if (normalized.contains('gif')) return 1; return 0.72; } + + List _feedUrls(Post post, {required bool mobile}) { + if ((post.providerId == 'realbooru' || post.providerId == 'paheal') && + !MediaUrlSelector.isVideo(post)) { + return [ + post.sampleUrl, + post.fileUrl, + post.previewUrl, + ].where((url) => url.trim().isNotEmpty).toSet().toList(growable: false); + } + return MediaUrlSelector.feed( + post, + mode: widget.mediaQualityMode, + mobile: mobile, + ); + } + + Map _headersFor(Post post) { + final lower = '${post.providerId} ${post.providerName} ' + '${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}' + .toLowerCase(); + return { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/', + if (lower.contains('realbooru')) + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36', + if (lower.contains('realbooru')) + 'Accept': + 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + if (lower.contains('realbooru')) + 'Referer': + 'https://realbooru.com/index.php?page=post&s=view&id=${post.id}', + if (lower.contains('paheal')) + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36', + if (lower.contains('paheal')) + 'Accept': + 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + if (lower.contains('paheal')) + 'Referer': 'https://rule34.paheal.net/post/view/${post.id}', + if (lower.contains('rule34') && !lower.contains('paheal')) + 'Referer': 'https://rule34.xxx/', + }; + } + + void _maybeResolveRealbooruPost() { + final post = widget.post; + if (!_needsRealbooruDetails(post) || + _resolvedRealbooruPosts.containsKey(post.cacheKey)) { + return; + } + Future(() async { + final result = await ref + .read(providerManagerProvider) + .getPost(post.providerId, post.id); + if (!mounted) return; + if (result is Success && result.data != null) { + final resolved = result.data!; + _resolvedRealbooruPosts[post.cacheKey] = resolved; + setState(() => _resolvedPost = resolved); + } + }); + } + + bool _needsRealbooruDetails(Post post) { + if (post.providerId != 'realbooru') return false; + final preview = post.previewUrl.toLowerCase(); + final sample = post.sampleUrl.toLowerCase(); + return post.fileUrl.isEmpty || + post.fileUrl == post.previewUrl || + post.fileUrl == post.sampleUrl || + preview.contains('/thumbnails/') || + sample.contains('/thumbnails/'); + } +} + +class _FeedVideoPreview extends StatefulWidget { + const _FeedVideoPreview({ + required this.videoUrl, + required this.headers, + required this.fallbackImageUrl, + required this.cacheWidth, + }); + + final String videoUrl; + final Map headers; + final String fallbackImageUrl; + final int cacheWidth; + + @override + State<_FeedVideoPreview> createState() => _FeedVideoPreviewState(); +} + +class _FeedVideoPreviewState extends State<_FeedVideoPreview> { + late final Player _player; + late final VideoController _controller; + bool _failed = false; + bool _ready = false; + int _requestId = 0; + + @override + void initState() { + super.initState(); + MediaKit.ensureInitialized(); + _player = Player(); + _controller = VideoController(_player); + _player.stream.error.listen((_) { + if (mounted) setState(() => _failed = true); + }); + _open(); + } + + @override + void didUpdateWidget(covariant _FeedVideoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _open(); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fallback = CachedNetworkImage( + imageUrl: widget.fallbackImageUrl, + httpHeaders: widget.headers, + memCacheWidth: widget.cacheWidth, + maxWidthDiskCache: widget.cacheWidth, + fit: BoxFit.cover, + placeholder: (context, url) => const LoadingSkeleton(), + errorWidget: (context, url, error) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.videocam_off_rounded)), + ), + ); + if (_failed) return fallback; + return Stack( + fit: StackFit.expand, + children: [ + fallback, + AnimatedOpacity( + opacity: _ready ? 1 : 0, + duration: AppMotion.duration(context, 180), + child: ColoredBox( + color: Colors.black, + child: Video( + controller: _controller, + fit: BoxFit.cover, + controls: null, + ), + ), + ), + ], + ); + } + + Future _open() async { + final requestId = ++_requestId; + if (mounted) { + setState(() { + _failed = false; + _ready = false; + }); + } + try { + await _VideoPreviewOpenQueue.run(() async { + if (!mounted || requestId != _requestId) return; + await _player.stop(); + await _player + .open( + Media(widget.videoUrl, httpHeaders: widget.headers), + play: false, + ) + .timeout(const Duration(seconds: 8)); + if (!mounted || requestId != _requestId) return; + await _player + .seek(const Duration(milliseconds: 300)) + .timeout(const Duration(seconds: 3)); + await _player.pause(); + }); + if (!mounted || requestId != _requestId) return; + setState(() => _ready = true); + } catch (_) { + if (mounted && requestId == _requestId) { + setState(() => _failed = true); + } + } + } +} + +class _VideoPreviewOpenQueue { + static const int _maxConcurrent = 2; + static final Queue Function()> _pending = Queue(); + static int _active = 0; + + static Future run(Future Function() task) { + final completer = Completer(); + _pending.add(() async { + try { + await task(); + if (!completer.isCompleted) completer.complete(); + } catch (error, stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + } + }); + _pump(); + return completer.future; + } + + static void _pump() { + while (_active < _maxConcurrent && _pending.isNotEmpty) { + final job = _pending.removeFirst(); + _active++; + unawaited( + job().whenComplete(() { + _active--; + _pump(); + }), + ); + } + } } class _FavoriteButton extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 895bf85..e667f71 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -683,10 +683,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1064,10 +1064,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" time: dependency: transitive description: @@ -1252,14 +1252,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webview_windows: - dependency: "direct main" - description: - name: webview_windows - sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" - url: "https://pub.dev" - source: hosted - version: "0.4.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 927ea66..3cfabee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,6 @@ dependencies: file_selector: ^1.0.3 path: ^1.9.0 media_kit_libs_android_video: ^1.3.8 - webview_windows: ^0.4.0 dev_dependencies: flutter_test: diff --git a/test/backend/feed_tag_filter_test.dart b/test/backend/feed_tag_filter_test.dart index 8247d9c..df4c5f4 100644 --- a/test/backend/feed_tag_filter_test.dart +++ b/test/backend/feed_tag_filter_test.dart @@ -50,6 +50,14 @@ void main() { expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse); }); + test('requested search tags can match tag prefixes', () { + final post = _post(tags: ['zenless_zone_zero', 'ellen_joe']); + + expect(postMatchesRequestedTags(post, ['zenless']), isTrue); + expect(postMatchesRequestedTags(post, ['zenless', 'ellen']), isTrue); + expect(postMatchesRequestedTags(post, ['zone']), isFalse); + }); + test('hidden post keys are filtered locally', () { final post = _post(tags: ['touhou']); final settings = AppSettings.defaults.copyWith( diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index ea749cf..51e54e9 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -38,9 +38,13 @@ void main() { File('${Directory.current.path}${Platform.pathSeparator}isar.dll'); if (Platform.isWindows && !copiedIsarDll.existsSync()) { final localAppData = Platform.environment['LOCALAPPDATA']; - final source = File( + final bundled = File( + '${Directory.current.path}\\third_party\\isar_flutter_libs\\windows\\isar.dll', + ); + final cached = File( '$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll', ); + final source = bundled.existsSync() ? bundled : cached; if (source.existsSync()) { await source.copy(copiedIsarDll.path); } @@ -85,26 +89,24 @@ void main() { 'e926', 'realbooru', 'xbooru', - 'cosbooru', + 'paheal', 'kemono', 'coomer', ]); - expect( - providers.singleWhere((provider) => provider.id == 'realbooru').enabled, - isFalse, - ); - expect( - providers.singleWhere((provider) => provider.id == 'cosbooru').enabled, - isTrue, - ); + final realbooru = + providers.singleWhere((provider) => provider.id == 'realbooru'); + expect(realbooru.enabled, isTrue); + expect(realbooru.apiType, 'realbooru_html'); + expect(realbooru.baseUrl, 'https://realbooru.com'); + expect(providers.where((provider) => provider.id == 'cosbooru'), isEmpty); final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru'); expect(xbooru.enabled, isTrue); expect(xbooru.baseUrl, 'https://xbooru.com'); expect(xbooru.apiType, 'gelbooru'); - final cosbooru = - providers.singleWhere((provider) => provider.id == 'cosbooru'); - expect(cosbooru.baseUrl, 'https://cos.lycore.co'); - expect(cosbooru.apiType, 'danbooru'); + final paheal = providers.singleWhere((provider) => provider.id == 'paheal'); + expect(paheal.enabled, isTrue); + expect(paheal.baseUrl, 'https://rule34.paheal.net'); + expect(paheal.apiType, 'paheal'); final kemono = providers.singleWhere((provider) => provider.id == 'kemono'); final coomer = providers.singleWhere((provider) => provider.id == 'coomer'); expect(kemono.enabled, isTrue); @@ -113,7 +115,8 @@ void main() { expect(coomer.apiType, 'coomer'); }); - test('provider repository migrates old CosBooru config', () async { + test('provider repository disables old CosBooru config without deleting it', + () async { final repository = ProviderRepository(databaseService); final now = DateTime(2026); await repository.saveProvider(ContentProviderConfig( @@ -134,10 +137,10 @@ void main() { final cosbooru = providers.singleWhere((provider) => provider.id == 'cosbooru'); - expect(cosbooru.baseUrl, 'https://cos.lycore.co'); - expect(cosbooru.apiType, 'danbooru'); - expect(cosbooru.enabled, isTrue); - expect(cosbooru.priority, 9); + expect(cosbooru.baseUrl, 'https://cos.booru.nl'); + expect(cosbooru.apiType, 'gelbooru'); + expect(cosbooru.enabled, isFalse); + expect(cosbooru.priority, 99); }); test('settings saveEnabledProviders updates provider configs', () async { diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index 62d8373..86892d0 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -84,6 +84,7 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider { this.posts, { this.failSearch = false, this.suggestions = const [], + this.suggestionDelay = Duration.zero, }); @override @@ -95,6 +96,8 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider { final List posts; final bool failSearch; final List suggestions; + final Duration suggestionDelay; + int suggestionCalls = 0; @override Future checkHealth() async => ProviderHealth( @@ -123,6 +126,10 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider { @override Future> suggestTags(String query, {int limit = 20}) async { + suggestionCalls++; + if (suggestionDelay > Duration.zero) { + await Future.delayed(suggestionDelay); + } return suggestions .where((suggestion) => suggestion.name.startsWith(query)) .take(limit) @@ -303,4 +310,120 @@ void main() { 'e926', }); }); + + test('tag suggestions timeout slow providers without blocking fast results', + () async { + final repository = FakeProviderRepository() + ..configs['fast'] = config('fast', 0) + ..configs['slow'] = config('slow', 1); + final fast = FakeProvider( + 'fast', + 'fast', + [], + suggestions: const [ + TagSuggestion( + name: 'cat_fast', + category: TagCategory.general, + postCount: 20, + providerId: 'fast', + ), + ], + ); + final slow = FakeProvider( + 'slow', + 'slow', + [], + suggestionDelay: const Duration(seconds: 3), + suggestions: const [ + TagSuggestion( + name: 'cat_slow', + category: TagCategory.general, + postCount: 200, + providerId: 'slow', + ), + ], + ); + final manager = ProviderManager( + repository, + FakeProviderFactory({'fast': fast, 'slow': slow}), + ); + final startedAt = DateTime.now(); + + final result = await manager.suggestTags('cat', limit: 4) + as Success>; + + expect(DateTime.now().difference(startedAt), + lessThan(const Duration(milliseconds: 2500))); + expect(result.data.map((item) => item.name), ['cat_fast']); + }); + + test('tag suggestions dedupe by name and keep highest post count', () async { + final repository = FakeProviderRepository() + ..configs['a'] = config('a', 0) + ..configs['b'] = config('b', 1); + final manager = ProviderManager( + repository, + FakeProviderFactory({ + 'a': FakeProvider( + 'a', + 'a', + [], + suggestions: const [ + TagSuggestion( + name: 'cat', + category: TagCategory.general, + postCount: 10, + providerId: 'a', + ), + ], + ), + 'b': FakeProvider( + 'b', + 'b', + [], + suggestions: const [ + TagSuggestion( + name: 'cat', + category: TagCategory.artist, + postCount: 100, + providerId: 'b', + ), + ], + ), + }), + ); + + final result = await manager.suggestTags('cat', limit: 4) + as Success>; + + expect(result.data, hasLength(1)); + expect(result.data.single.providerId, 'b'); + expect(result.data.single.postCount, 100); + }); + + test('tag suggestions cache repeated prefix results', () async { + final repository = FakeProviderRepository()..configs['a'] = config('a', 0); + final provider = FakeProvider( + 'a', + 'a', + [], + suggestions: const [ + TagSuggestion( + name: 'cat', + category: TagCategory.general, + postCount: 10, + providerId: 'a', + ), + ], + ); + final manager = ProviderManager( + repository, + FakeProviderFactory({'a': provider}), + ); + + await manager.suggestTags('cat', limit: 4); + await manager.suggestTags('cat', limit: 4); + + expect(provider.suggestionCalls, 1); + }); } diff --git a/test/backend/provider_parser_test.dart b/test/backend/provider_parser_test.dart index 0d413e2..1e56673 100644 --- a/test/backend/provider_parser_test.dart +++ b/test/backend/provider_parser_test.dart @@ -5,10 +5,10 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart'; import 'package:gel_rule_app/backend/models/content_provider_config.dart'; -import 'package:gel_rule_app/backend/providers/danbooru_provider.dart'; +import 'package:gel_rule_app/backend/providers/custom_provider.dart'; import 'package:gel_rule_app/backend/providers/kemono_provider.dart'; import 'package:gel_rule_app/backend/providers/provider_factory.dart'; -import 'package:gel_rule_app/backend/providers/realbooru_provider.dart'; +import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; void main() { @@ -231,31 +231,30 @@ void main() { expect(posts.single.rating, 'unknown'); }); - test('seed providers include CosBooru and no Paheal', () { + test('seed providers include Realbooru HTML Paheal and exclude CosBooru', () { + final paheal = ProviderRepository.seedProviders() + .where((provider) => provider.id == 'paheal') + .single; + expect(paheal.enabled, isTrue); + expect(paheal.apiType, 'paheal'); + expect(paheal.baseUrl, 'https://rule34.paheal.net'); final realbooru = ProviderRepository.seedProviders() .where((provider) => provider.id == 'realbooru') .single; - - expect(realbooru.enabled, isFalse); - expect(realbooru.apiType, 'realbooru'); + expect(realbooru.enabled, isTrue); + expect(realbooru.apiType, 'realbooru_html'); expect(realbooru.baseUrl, 'https://realbooru.com'); expect( ProviderRepository.seedProviders() - .where((provider) => provider.id == 'paheal'), + .where((provider) => provider.id == 'cosbooru'), isEmpty, ); - final cosbooru = ProviderRepository.seedProviders() - .where((provider) => provider.id == 'cosbooru') - .single; final xbooru = ProviderRepository.seedProviders() .where((provider) => provider.id == 'xbooru') .single; expect(xbooru.enabled, isTrue); expect(xbooru.apiType, 'gelbooru'); expect(xbooru.baseUrl, 'https://xbooru.com'); - expect(cosbooru.enabled, isTrue); - expect(cosbooru.apiType, 'danbooru'); - expect(cosbooru.baseUrl, 'https://cos.lycore.co'); final kemono = ProviderRepository.seedProviders() .where((provider) => provider.id == 'kemono') .single; @@ -268,7 +267,7 @@ void main() { expect(coomer.apiType, 'coomer'); }); - test('provider factory creates Realbooru provider', () { + test('provider factory treats Realbooru api type as unsupported', () { final now = DateTime(2026); final provider = ProviderFactory().create(ContentProviderConfig( id: 'realbooru', @@ -283,25 +282,25 @@ void main() { updatedAt: now, )); - expect(provider, isA()); + expect(provider, isA()); }); - test('provider factory creates CosBooru as Danbooru provider', () { + test('provider factory creates Realbooru HTML provider', () { final now = DateTime(2026); final provider = ProviderFactory().create(ContentProviderConfig( - id: 'cosbooru', - name: 'CosBooru', - baseUrl: 'https://cos.lycore.co', - apiType: 'danbooru', + id: 'realbooru', + name: 'Realbooru', + baseUrl: 'https://realbooru.com', + apiType: 'realbooru_html', enabled: true, - priority: 8, + priority: 7, timeoutSeconds: 20, customHeaders: const {}, createdAt: now, updatedAt: now, )); - expect(provider, isA()); + expect(provider, isA()); }); test('provider factory creates Kemono and Coomer artist providers', () { diff --git a/test/backend/search_service_test.dart b/test/backend/search_service_test.dart index b413dde..ce9915b 100644 --- a/test/backend/search_service_test.dart +++ b/test/backend/search_service_test.dart @@ -14,10 +14,10 @@ class FakeSearchRepository implements SearchRepository { } @override - Future>> recent({int limit = 20}) async { + Future>> recent({int? limit}) async { final sorted = [...items] ..sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); - return Success(sorted.take(limit).toList()); + return Success((limit == null ? sorted : sorted.take(limit)).toList()); } @override diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart index 680d45f..ad73833 100644 --- a/test/shared/tag_input_search_bar_test.dart +++ b/test/shared/tag_input_search_bar_test.dart @@ -99,6 +99,83 @@ void main() { expect(find.text('new_tag'), findsOneWidget); }); + testWidgets('old external value does not restore deleted chip', + (tester) async { + String? changed; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'old_tag keep_tag', + onSubmitted: (_) {}, + onChanged: (value) { + changed = value; + }, + ), + )); + + await tester.tap(find.byIcon(Icons.close_rounded).first); + await tester.pump(); + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'old_tag keep_tag', + onSubmitted: (_) {}, + onChanged: (value) { + changed = value; + }, + ), + )); + await tester.pump(); + + expect(find.text('old_tag'), findsNothing); + expect(find.text('keep_tag'), findsOneWidget); + expect(changed, 'keep_tag'); + }); + + testWidgets('focused draft is not replaced by stale external value', + (tester) async { + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'old_tag', + onSubmitted: (_) {}, + ), + )); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'new_tag'); + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'old_tag', + onSubmitted: (_) {}, + ), + )); + await tester.pump(); + + expect(find.text('old_tag'), findsOneWidget); + expect(find.text('new_tag'), findsOneWidget); + }); + + testWidgets('matching external draft does not become a chip while focused', + (tester) async { + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: '', + onSubmitted: (_) {}, + ), + )); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'draft_tag'); + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'draft_tag', + onSubmitted: (_) {}, + ), + )); + await tester.pump(); + + expect(find.byType(InputChip), findsNothing); + expect(find.text('draft_tag'), findsOneWidget); + }); + testWidgets('suggestion appends active tag as a chip', (tester) async { String? applied; await tester.pumpWidget(_Harness( @@ -118,8 +195,9 @@ void main() { ), )); + await tester.tap(find.byType(TextField)); await tester.enterText(find.byType(TextField), 'tou'); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('touhou')); await tester.pump(); @@ -127,6 +205,36 @@ void main() { expect(applied, 'touhou'); }); + testWidgets('stale suggestions are hidden for a newer active token', + (tester) async { + await tester.pumpWidget(_Harness( + child: SizedBox( + width: 360, + child: TagInputSearchBar( + suggestions: const [ + TagSuggestion( + name: 'touhou', + category: TagCategory.copyright, + postCount: 1200, + providerId: 'gelbooru', + ), + ], + onSubmitted: (_) {}, + ), + ), + )); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'tou'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('touhou'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'cat'); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('touhou'), findsNothing); + }); + testWidgets('suggestions overlay does not resize search bar', (tester) async { await tester.pumpWidget(_Harness( child: SizedBox( @@ -140,7 +248,7 @@ void main() { providerId: 'gelbooru', ), TagSuggestion( - name: 'tail', + name: 'tou_tail', category: TagCategory.general, postCount: 900, providerId: 'e621', @@ -153,11 +261,12 @@ void main() { final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height; await tester.tap(find.byType(TextField)); - await tester.pump(); + await tester.enterText(find.byType(TextField), 'tou'); + await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); expect(find.text('touhou'), findsOneWidget); - expect(find.text('tail'), findsOneWidget); + expect(find.text('tou_tail'), findsOneWidget); expect( tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight); }); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b3e212f..35b6858 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,7 +13,6 @@ #include #include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( @@ -30,6 +29,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("UrlLauncherWindows")); VolumeControllerPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); - WebviewWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WebviewWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 31a0acb..2b8213a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video url_launcher_windows volume_controller - webview_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST