feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements

This commit is contained in:
2026-09-03 16:40:37 +03:00
parent 7cccb2cd8d
commit 45faa12133
55 changed files with 4263 additions and 2076 deletions
+56 -1
View File
@@ -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,
+3 -3
View File
@@ -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';
}
+7 -9
View File
@@ -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.',
+5
View File
@@ -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<GoRouter>((ref) {
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
],
),
],
+7
View File
@@ -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<DownloadedMedia?> ? result.data : null;
});
final postMediaHeadersProvider =
FutureProvider.family<Map<String, String>, Post>((ref, post) async {
final result = await ref.watch(providerManagerProvider).getMediaHeaders(post);
return result is Success<Map<String, String>> ? result.data : const {};
});
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
final manager = ref.watch(downloadManagerServiceProvider);
return manager.stream;
@@ -24,6 +24,14 @@ abstract class ContentProvider {
Future<ProviderHealth> checkHealth();
}
abstract class PostPageProvider {
String? postPageUrl(Post post);
}
abstract class MediaHeadersProvider {
Map<String, String> mediaHeaders(Post post);
}
abstract class CommentProvider {
Future<List<PostComment>> getComments(String postId);
}
+5 -1
View File
@@ -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<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+8 -1
View File
@@ -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<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+17 -1
View File
@@ -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<String, String> get queryParameters => _queryParameters;
@override
String postPageUrl(Post post) =>
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> 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<List<Post>> searchPosts({
required List<String> tags,
+41 -6
View File
@@ -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<ArtistProfile> _rankArtists(List<ArtistProfile> 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<dynamic> _postItems(dynamic data) {
+8 -1
View File
@@ -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<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+12 -4
View File
@@ -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(
+284 -25
View File
@@ -18,12 +18,30 @@ class ProviderManager {
final ProviderRepository _repository;
final ProviderFactory _factory;
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
final Set<String> _softRetryKeys = {};
final _softRetryController =
StreamController<ProviderSoftRetryResult>.broadcast();
_SuggestionProviderCacheEntry? _suggestionProviderCache;
static const _suggestionCacheTtl = Duration(seconds: 45);
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
static const _suggestionTimeout = Duration(milliseconds: 1500);
Stream<ProviderSoftRetryResult> get softRetryResults =>
_softRetryController.stream;
Future<Result<List<ContentProviderConfig>>> loadConfigs({
bool enabledOnly = true,
}) async {
await _repository.ensureSeedProviders();
return _repository.getProviders(enabledOnly: enabledOnly);
final result = await _repository.getProviders(enabledOnly: enabledOnly);
if (result is Error<List<ContentProviderConfig>>) return result;
final configs = (result as Success<List<ContentProviderConfig>>)
.data
.where((config) => !_isLegacyRemovedConfig(config))
.toList(growable: false);
return Success(configs);
}
Future<Result<List<ContentProviderConfig>>> 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<void>.new,
);
}
Future<Result<void>> 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<Result<void>> deleteProvider(String id) =>
_repository.deleteProvider(id);
Future<Result<void>> deleteProvider(String id) {
return _repository.deleteProvider(id).then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<List<ProviderHealth>>> 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<String> 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<void>.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<Result<List<PostComment>>> getComments(
String providerId,
String postId,
@@ -282,6 +391,39 @@ class ProviderManager {
}
}
Future<Result<String?>> getPostPageUrl(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).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<Result<Map<String, String>>> getMediaHeaders(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).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<Result<Post>> 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<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).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 = <String, TagSuggestion>{};
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<List<TagSuggestion>> _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<Result<List<ContentProvider>>> 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<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>)
.data
.whereType<TagSuggestionProvider>()
.toList();
final suggestions = <String, TagSuggestion>{};
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 = <ContentProvider>[
for (final provider
in (providersResult as Success<List<ContentProvider>>).data)
if (provider is TagSuggestionProvider) provider,
];
_suggestionProviderCache = _SuggestionProviderCacheEntry(
DateTime.now(),
providers,
);
return Success(providers);
}
void _clearSuggestionCaches() {
_suggestionCache.clear();
_suggestionProviderCache = null;
}
Future<List<R>> _runLimited<T, R>(
@@ -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<TagSuggestion> items;
}
class _SuggestionProviderCacheEntry {
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
final DateTime createdAt;
final List<ContentProvider> 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<String> tags;
final int page;
final int limit;
final TopPeriodFilter topPeriod;
final String? rating;
final List<Post> posts;
}
@@ -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<List<Post>> searchPosts({
required List<String> 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<String>(
'/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<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/index.php',
queryParameters: {'page': 'post', 's': 'view', 'id': id},
options: Options(responseType: ResponseType.plain),
);
return _parseDetails(id, response.data ?? '');
}
@override
Future<ProviderHealth> 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<String, String> 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<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final response = await _dio.get<dynamic>(
'/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<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+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'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final videoSources = RegExp(
r'''<source[^>]+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'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
.firstMatch(html)
?.group(0) ??
'',
'value',
);
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
? _attribute(imageTag ?? '', 'alt')
: RegExp(
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
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<String> _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<String, List<String>> _tagGroups(List<String> 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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
List<String> _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 [];
}
}
@@ -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<List<Post>> searchPosts({
required List<String> 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<dynamic>(
'/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<Post?> getPost(String id) async {
final response = await dio.get<dynamic>(
'/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<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final response = await dio.get<dynamic>(
'/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',
);
}
}
}
@@ -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<List<Post>> searchPosts({
required List<String> 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<String>(
path,
options: Options(responseType: ResponseType.plain),
);
return _parseList(response.data ?? '').take(limit).toList(growable: false);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/post/view/$id',
options: Options(responseType: ResponseType.plain),
);
return _parseSingle(id, response.data ?? '');
}
@override
Future<ProviderHealth> 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<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
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'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
caseSensitive: false,
).firstMatch(block)?.group(1) ??
'',
);
final previewUrl = _absolute(
RegExp(r'''<img[^>]+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'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
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'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final tagsText = RegExp(
r'''<meta[^>]+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<String> _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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
}
@@ -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<String, String> mediaHeaders(Post post) => const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
'Referer': 'https://rule34.xxx/',
};
@override
Future<List<Post>> searchPosts({
required List<String> tags,
@@ -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;
@@ -23,11 +23,12 @@ class SearchRepository {
});
}
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
Future<Result<List<SearchHistory>>> 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();
});
}
@@ -16,9 +16,12 @@ class DownloadManagerService {
final DownloadedMediaService? _downloadedMediaService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
bool _queuePaused = false;
Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
bool get queuePaused => _queuePaused;
Future<DownloadTask> 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<void> 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<void> _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();
});
}
+34 -2
View File
@@ -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<String>('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<String, String> _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}',
};
}
}
+8 -1
View File
@@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List<String> 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) {
+1 -1
View File
@@ -32,7 +32,7 @@ class SearchService {
);
}
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
+36 -1
View File
@@ -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<String, int> videoPlaybackPositions;
final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines;
final List<String> 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<String, int>? videoPlaybackPositions,
List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines,
List<String>? 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<String, int>.from(
(json['videoPlaybackPositions'] as Map?) ??
defaults.videoPlaybackPositions,
),
hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
),
@@ -469,6 +480,30 @@ class SettingsService {
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
}
Future<Result<void>> 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<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final existing = settings.videoPlaybackPositions[cacheKey] ?? 0;
if ((existing - milliseconds).abs() < 1000) return const Success(null);
final positions = <String, int>{
...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<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
@@ -35,7 +35,38 @@ class ViewedHistoryService {
);
}
Future<Result<List<ViewedPostEntry>>> getViewedPostEntries() async {
final viewedResult = await _repository.recent();
if (viewedResult is Error<List<ViewedPost>>) {
return Error(viewedResult.failure);
}
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
final postsResult = await _postRepository.getCachedPostsByKeys(
viewed.map((item) => item.viewedKey),
);
if (postsResult is Error<List<Post>>) {
return Error(postsResult.failure);
}
final postsByKey = {
for (final post in (postsResult as Success<List<Post>>).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<Result<void>> clearHistory() {
return _repository.clear();
}
}
class ViewedPostEntry {
const ViewedPostEntry({required this.post, required this.viewedAt});
final Post post;
final DateTime viewedAt;
}
@@ -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<FavoritesScreen> createState() => _FavoritesScreenState();
}
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
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<int>(
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<Post> posts,
AppSettings settings,
Set<String> 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<Post> posts,
AppSettings settings,
Set<String> 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<String, List<Post>> favoriteArtistAlbums(List<Post> posts) {
final groups = <String, List<Post>>{};
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())),
);
}
@@ -12,11 +12,22 @@ final feedControllerProvider =
class FeedController extends AsyncNotifier<FeedState> {
Timer? _providerDebounce;
Timer? _suggestionDebounce;
StreamSubscription<ProviderSoftRetryResult>? _softRetrySubscription;
int _loadRequestId = 0;
int _suggestionRequestId = 0;
@override
Future<FeedState> 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<FeedState> {
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<void> refresh() async {
@@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier<FeedState> {
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<void> loadNextPage() async {
@@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier<FeedState> {
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 = <Post>[];
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<FeedState> {
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<FeedState> {
Future<void> 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<List<TagSuggestion>>
? result.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
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<List<TagSuggestion>>
? result.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
});
}
Future<void> setProviders(List<String> 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<FeedState> {
Future<void> 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<FeedState> {
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<FeedState> {
tagSuggestions: [],
clearRating: true,
posts: [],
hasMore: true,
emptyPageStreak: 0,
),
);
final settings = await _settings();
@@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier<FeedState> {
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<String> left, List<String> 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<String?> _providerStatusMessage(FeedState current) async {
final result = await ref.read(providerRepositoryProvider).getDiagnostics();
if (result is! Success<List<ProviderDiagnostics>>) 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) {
@@ -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<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen> {
class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver {
final _scrollController = ScrollController();
final Set<String> _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<FeedScreen> {
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<FeedScreen> {
@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<FeedScreen> {
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<FeedScreen> {
onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: _clearFilters,
onRandom: () => _openRandom(state.posts),
selectionMode: _selectionMode,
onToggleSelectionMode: () {
setState(() {
@@ -228,6 +257,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
.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<FeedScreen> {
context.go(location);
}
void _openRandom(List<Post> posts) {
if (posts.isEmpty) return;
final post = posts[Random().nextInt(posts.length)];
context.push('/post/${post.providerId}/${post.id}', extra: post);
}
Future<void> _applySearchQuery(String query) async {
await ref.read(feedControllerProvider.notifier).search(query);
if (_scrollController.hasClients) {
@@ -11,7 +11,9 @@ class FeedState {
this.providers = const [],
this.isLoadingMore = false,
this.hasMore = true,
this.emptyPageStreak = 0,
this.error,
this.providerStatusMessage,
});
final List<Post> posts;
@@ -23,7 +25,9 @@ class FeedState {
final List<ContentProviderConfig> providers;
final bool isLoadingMore;
final bool hasMore;
final int emptyPageStreak;
final String? error;
final String? providerStatusMessage;
FeedState copyWith({
List<Post>? posts,
@@ -36,8 +40,11 @@ class FeedState {
List<ContentProviderConfig>? 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,
);
}
}
@@ -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<String> onSuggestionTap;
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
final VoidCallback? onToggleSelectionMode;
final VoidCallback? onRandom;
final String? providerStatusMessage;
final bool selectionMode;
@override
@@ -122,9 +126,24 @@ class _FeedToolbarState extends State<FeedToolbar> {
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<FeedToolbar> {
},
),
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<FeedToolbar> {
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(
@@ -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<Post?, PostDetailsArgs>((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<Post?> 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<Post?> && 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<Post?> && 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<Post?> && remote.data != null) {
yield remote.data;
@@ -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<String> favoriteKeys,
MediaQualityMode qualityMode,
AppStrings strings,
bool mediaGestureLocked,
ValueChanged<bool>? 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<void> _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<String?>) 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<void> _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<Post> posts;
final int initialIndex;
final Widget Function(BuildContext context, Post post) buildDetails;
final Widget Function(
BuildContext context,
Post post,
bool mediaGestureLocked,
ValueChanged<bool> 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<String, String> _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/',
};
}
}
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -89,7 +89,9 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
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(
@@ -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, SearchState>(SearchController.new);
class SearchController extends AsyncNotifier<SearchState> {
Timer? _suggestionDebounce;
int _suggestionRequestId = 0;
@override
Future<SearchState> build() async {
ref.onDispose(() => _suggestionDebounce?.cancel());
final recent = await _recent();
return SearchState(recent: recent);
}
@@ -17,18 +23,42 @@ class SearchController extends AsyncNotifier<SearchState> {
Future<void> 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<List<TagSuggestion>>
? suggestionsResult.data
: <TagSuggestion>[];
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<List<TagSuggestion>>
? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: suggestions,
),
);
});
}
Future<void> clearHistory() async {
@@ -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<SearchHistory> items;
final ValueChanged<String> onTap;
@override
State<RecentSearches> createState() => _RecentSearchesState();
}
class _RecentSearchesState extends State<RecentSearches> {
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',
),
),
],
);
@@ -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<List<HiddenPostRow>>((ref) async {
final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final repository = ref.watch(postRepositoryProvider);
final rows = <HiddenPostRow>[];
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?>) 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'),
),
),
);
}
}
@@ -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<int> onChanged;
static const colors = <int>[
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,
),
@@ -4,11 +4,13 @@ import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final viewedControllerProvider =
AsyncNotifierProvider<ViewedController, List<Post>>(ViewedController.new);
AsyncNotifierProvider<ViewedController, List<ViewedTimelineGroup>>(
ViewedController.new,
);
class ViewedController extends AsyncNotifier<List<Post>> {
class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
@override
Future<List<Post>> build() => _load();
Future<List<ViewedTimelineGroup>> build() => _load();
Future<void> refresh() async {
state = AsyncData(await _load());
@@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier<List<Post>> {
state = const AsyncData([]);
}
Future<List<Post>> _load() async {
Future<List<ViewedTimelineGroup>> _load() async {
final result =
await ref.read(viewedHistoryServiceProvider).getViewedPosts();
return result is Success<List<Post>> ? result.data : const [];
await ref.read(viewedHistoryServiceProvider).getViewedPostEntries();
if (result is! Success<List<ViewedPostEntry>>) return const [];
return groupViewedTimeline(result.data);
}
}
class ViewedTimelineGroup {
const ViewedTimelineGroup({required this.label, required this.items});
final String label;
final List<ViewedPostEntry> items;
}
List<ViewedTimelineGroup> groupViewedTimeline(
List<ViewedPostEntry> entries, {
DateTime? now,
}) {
final today = DateTime(now?.year ?? DateTime.now().year,
now?.month ?? DateTime.now().month, now?.day ?? DateTime.now().day);
final buckets = <String, List<ViewedPostEntry>>{};
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')}';
@@ -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 ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
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<void> _toggleFavorite(
WidgetRef ref,
Post post,
AppSettings settings,
Set<String> 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),
),
),
);
}
}
+110 -77
View File
@@ -54,6 +54,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
OverlayEntry? _suggestionsOverlay;
Timer? _debounce;
List<String> _tags = [];
String _lastExternalValue = '';
bool _localDirty = false;
@override
void initState() {
@@ -62,15 +64,20 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_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<TagInputSearchBar> {
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<TagInputSearchBar> {
}
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<TagInputSearchBar> {
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<TagInputSearchBar> {
}
void _handleDraftChanged(String value) {
_localDirty = true;
final composing = _controller.value.composing;
if (!composing.isValid &&
value.isNotEmpty &&
@@ -286,6 +302,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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<TagInputSearchBar> {
_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<TagInputSearchBar> {
}
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<TagInputSearchBar> {
}
void _removeTag(String tag) {
_localDirty = true;
setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true);
});
@@ -333,6 +355,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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<TagInputSearchBar> {
}
void _notifyChanged() {
_localDirty = true;
widget.onChanged?.call(_query);
_syncSuggestionsOverlay();
}
List<String> _parseTags(String query) {
@@ -372,6 +397,14 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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,
),
),
],
],
),
),
);
},
),
),
);
+14 -11
View File
@@ -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),
};
}
+274 -9
View File
@@ -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<PostCard> createState() => _PostCardState();
ConsumerState<PostCard> createState() => _PostCardState();
}
class _PostCardState extends State<PostCard> {
class _PostCardState extends ConsumerState<PostCard> {
static final Map<String, Post> _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<PostCard> {
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<PostCard> {
if (normalized.contains('gif')) return 1;
return 0.72;
}
List<String> _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<String, String> _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<void>(() async {
final result = await ref
.read(providerManagerProvider)
.getPost(post.providerId, post.id);
if (!mounted) return;
if (result is Success<Post?> && 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<String, String> 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<void> _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<Future<void> Function()> _pending = Queue();
static int _active = 0;
static Future<void> run(Future<void> Function() task) {
final completer = Completer<void>();
_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 {