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
+1
View File
@@ -4,6 +4,7 @@ build/
dist/ dist/
/isar.dll /isar.dll
.idea/ .idea/
.vscode/
*.iml *.iml
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
+56 -1
View File
@@ -221,6 +221,19 @@ class _DownloadPanel extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final strings = ref.watch(appStringsProvider); 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( return Material(
elevation: 12, elevation: 12,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -232,6 +245,39 @@ class _DownloadPanel extends ConsumerWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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) ...[ for (final task in tasks) ...[
Row( Row(
children: [ children: [
@@ -252,6 +298,15 @@ class _DownloadPanel extends ConsumerWidget {
.retry(task.id), .retry(task.id),
icon: const Icon(Icons.refresh_rounded), 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), const SizedBox(height: 6),
@@ -344,7 +399,7 @@ class _StartupErrorScreen extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
strings.ru 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', : 'Could not start local database',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center, textAlign: TextAlign.center,
+3 -3
View File
@@ -75,8 +75,8 @@ class AppStrings {
String get hideViewed => ru ? 'Скрывать просмотренное' : 'Hide viewed posts'; String get hideViewed => ru ? 'Скрывать просмотренное' : 'Hide viewed posts';
String get smartBlacklist => ru ? 'Умный blacklist' : 'Smart blacklist'; String get smartBlacklist => ru ? 'Умный blacklist' : 'Smart blacklist';
String get whitelistedTags => ru ? 'Whitelist тегов' : 'Whitelisted tags'; String get whitelistedTags => ru ? 'Whitelist тегов' : 'Whitelisted tags';
String get cacheMaxItems => ru ? 'Лимит кэша' : 'Cache max items'; String get cacheMaxItems => ru ? 'Лимит кеша' : 'Cache max items';
String get clearCache => ru ? 'Очистить кэш' : 'Clear cache'; String get clearCache => ru ? 'Очистить кеш' : 'Clear cache';
String get clearViewed => ru ? 'Очистить историю' : 'Clear viewed history'; String get clearViewed => ru ? 'Очистить историю' : 'Clear viewed history';
String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON'; String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON';
String get importJson => ru ? 'Импорт JSON' : 'Import JSON'; String get importJson => ru ? 'Импорт JSON' : 'Import JSON';
@@ -98,5 +98,5 @@ class AppStrings {
String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat'; String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat';
String get fullscreen => ru ? 'На весь экран' : 'Fullscreen'; String get fullscreen => ru ? 'На весь экран' : 'Fullscreen';
String get exitFullscreen => String get exitFullscreen =>
ru ? 'Выйти из полного экрана' : 'Exit fullscreen'; ru ? 'Выйти из полноэкранного режима' : 'Exit fullscreen';
} }
+7 -9
View File
@@ -43,29 +43,27 @@ const lunarisChangelog = [
), ),
LunarisChange( LunarisChange(
version: '1.1.0', version: '1.1.0',
title: 'Lunaris rebrand and CosBooru', title: 'Lunaris rebrand and providers',
bullets: [ bullets: [
'RuleGel is now Lunaris while keeping the same package identity for updates.', '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.', '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.', 'Video player UI preferences are saved across fullscreen, details and restarts.',
], ],
), ),
LunarisChange( LunarisChange(
version: '1.0.2', version: '1.0.2',
title: 'Realbooru safety fix', title: 'Provider safety fix',
bullets: [ bullets: [
'Realbooru stays available in Providers, but is disabled by default while its public DAPI is offline.', 'Known-offline legacy providers are disabled on startup.',
'Provider diagnostics now surface the Realbooru API reason instead of a generic exception.', 'Provider diagnostics now surface API reasons instead of generic exceptions.',
'Existing enabled Realbooru configs are disabled on startup so the main feed is not polluted by a known-offline API.', 'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.',
], ],
), ),
LunarisChange( LunarisChange(
version: '1.0.1', version: '1.0.1',
title: 'Realbooru and mobile polish', title: 'Mobile polish',
bullets: [ bullets: [
'Added Realbooru as a default enabled provider.',
'Startup update checks now look for new Gitea releases every launch.', 'Startup update checks now look for new Gitea releases every launch.',
'Refresh-rate controls are Android-only; desktop keeps stable app motion.', 'Refresh-rate controls are Android-only; desktop keeps stable app motion.',
'Tag chip input no longer jumps while typing.', '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/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart'; import '../features/providers/presentation/providers_screen.dart';
import '../features/search/presentation/search_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/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart'; import '../features/viewed/presentation/viewed_screen.dart';
import '../shared/widgets/app_shell.dart'; import '../shared/widgets/app_shell.dart';
@@ -105,6 +106,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings', path: '/settings',
builder: (context, state) => const SettingsScreen(), 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 '../providers/provider_manager.dart';
import '../models/download_task.dart'; import '../models/download_task.dart';
import '../models/downloaded_media.dart'; import '../models/downloaded_media.dart';
import '../models/post.dart';
import '../repositories/collection_repository.dart'; import '../repositories/collection_repository.dart';
import '../repositories/downloaded_media_repository.dart'; import '../repositories/downloaded_media_repository.dart';
import '../repositories/favorite_repository.dart'; import '../repositories/favorite_repository.dart';
@@ -188,6 +189,12 @@ final downloadedMediaByKeyProvider =
return result is Success<DownloadedMedia?> ? result.data : null; 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 downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
final manager = ref.watch(downloadManagerServiceProvider); final manager = ref.watch(downloadManagerServiceProvider);
return manager.stream; return manager.stream;
@@ -24,6 +24,14 @@ abstract class ContentProvider {
Future<ProviderHealth> checkHealth(); Future<ProviderHealth> checkHealth();
} }
abstract class PostPageProvider {
String? postPageUrl(Post post);
}
abstract class MediaHeadersProvider {
Map<String, String> mediaHeaders(Post post);
}
abstract class CommentProvider { abstract class CommentProvider {
Future<List<PostComment>> getComments(String postId); 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 '../models/top_period_filter.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class DanbooruProvider implements ContentProvider, CommentProvider { class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider {
DanbooruProvider({ DanbooruProvider({
required this.id, required this.id,
required this.name, required this.name,
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
final Dio _dio; final Dio _dio;
final Map<String, String> _queryParameters; final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override @override
Future<List<Post>> searchPosts({ Future<List<Post>> searchPosts({
required List<String> tags, required List<String> tags,
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class E621Provider class E621Provider
implements ContentProvider, TagSuggestionProvider, CommentProvider { implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
E621Provider({ E621Provider({
required this.id, required this.id,
required this.name, required this.name,
@@ -29,6 +33,9 @@ class E621Provider
final Dio _dio; final Dio _dio;
final Map<String, String> _queryParameters; final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override @override
Future<List<Post>> searchPosts({ Future<List<Post>> searchPosts({
required List<String> tags, required List<String> tags,
+17 -1
View File
@@ -15,7 +15,9 @@ class GelbooruProvider
ContentProvider, ContentProvider,
CommentProvider, CommentProvider,
TagSuggestionProvider, TagSuggestionProvider,
TagMetadataProvider { TagMetadataProvider,
PostPageProvider,
MediaHeadersProvider {
GelbooruProvider({ GelbooruProvider({
required this.id, required this.id,
required this.name, required this.name,
@@ -36,6 +38,20 @@ class GelbooruProvider
Dio get dio => _dio; Dio get dio => _dio;
Map<String, String> get queryParameters => _queryParameters; 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 @override
Future<List<Post>> searchPosts({ Future<List<Post>> searchPosts({
required List<String> tags, required List<String> tags,
+41 -6
View File
@@ -106,10 +106,15 @@ class KemonoProvider
'itemsPerPage': limit.clamp(1, 100), 'itemsPerPage': limit.clamp(1, 100),
}, },
); );
final artists = _artistsFromResponse(response.data) final artists = _rankArtists(
.where((artist) => _artistsFromResponse(response.data)
service == null || service.isEmpty || artist.service == service) .where((artist) =>
.toList(growable: false); service == null ||
service.isEmpty ||
artist.service == service)
.toList(growable: false),
searchText,
);
if (artists.isNotEmpty) return artists; if (artists.isNotEmpty) return artists;
} catch (_) { } catch (_) {
// Fall through to the indexed creator endpoints. // Fall through to the indexed creator endpoints.
@@ -126,7 +131,7 @@ class KemonoProvider
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(), if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
}, },
); );
return _artistsFromResponse(response.data); return _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) { } catch (_) {
return _listArtistsFromPublicApi( return _listArtistsFromPublicApi(
service: service, service: service,
@@ -177,6 +182,7 @@ class KemonoProvider
)); ));
} }
if (mapped.isNotEmpty || posts.isEmpty) { if (mapped.isNotEmpty || posts.isEmpty) {
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false); return mapped.take(limit).toList(growable: false);
} }
} catch (error) { } catch (error) {
@@ -290,7 +296,36 @@ class KemonoProvider
artist.displayName.toLowerCase().contains(queryText) || artist.displayName.toLowerCase().contains(queryText) ||
artist.id.toLowerCase().contains(queryText))) artist.id.toLowerCase().contains(queryText)))
.toList(growable: false); .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) { List<dynamic> _postItems(dynamic data) {
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class MoebooruProvider class MoebooruProvider
implements ContentProvider, TagSuggestionProvider, CommentProvider { implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
MoebooruProvider({ MoebooruProvider({
required this.id, required this.id,
required this.name, required this.name,
@@ -29,6 +33,9 @@ class MoebooruProvider
final Dio _dio; final Dio _dio;
final Map<String, String> _queryParameters; final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
@override @override
Future<List<Post>> searchPosts({ Future<List<Post>> searchPosts({
required List<String> tags, required List<String> tags,
+12 -4
View File
@@ -7,8 +7,9 @@ import 'e621_provider.dart';
import 'gelbooru_provider.dart'; import 'gelbooru_provider.dart';
import 'kemono_provider.dart'; import 'kemono_provider.dart';
import 'moebooru_provider.dart'; import 'moebooru_provider.dart';
import 'realbooru_provider.dart'; import 'realbooru_html_provider.dart';
import 'rule34_provider.dart'; import 'rule34_provider.dart';
import 'rule34_paheal_provider.dart';
class ProviderFactory { class ProviderFactory {
ContentProvider create(ContentProviderConfig config) { ContentProvider create(ContentProviderConfig config) {
@@ -41,13 +42,20 @@ class ProviderFactory {
dioClient: client, dioClient: client,
queryParameters: queryParameters, queryParameters: queryParameters,
); );
case 'realbooru': case 'paheal':
return RealbooruProvider( case 'rule34_paheal':
return Rule34PahealProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
);
case 'realbooru_html':
return RealbooruHtmlProvider(
id: config.id, id: config.id,
name: config.name, name: config.name,
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
dioClient: client, dioClient: client,
queryParameters: queryParameters,
); );
case 'danbooru': case 'danbooru':
return DanbooruProvider( return DanbooruProvider(
+284 -25
View File
@@ -18,12 +18,30 @@ class ProviderManager {
final ProviderRepository _repository; final ProviderRepository _repository;
final ProviderFactory _factory; 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({ Future<Result<List<ContentProviderConfig>>> loadConfigs({
bool enabledOnly = true, bool enabledOnly = true,
}) async { }) async {
await _repository.ensureSeedProviders(); 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({ Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
@@ -97,20 +115,34 @@ class ProviderManager {
Failure(code: 'not_found', message: 'Provider not found'), Failure(code: 'not_found', message: 'Provider not found'),
); );
} }
return _repository.saveProvider( return _repository
.saveProvider(
config.copyWith(enabled: enabled, updatedAt: DateTime.now()), config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
); )
.then((result) {
_clearSuggestionCaches();
return result;
});
}, },
onError: Error<void>.new, onError: Error<void>.new,
); );
} }
Future<Result<void>> addCustomProvider(ContentProviderConfig config) { 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) => Future<Result<void>> deleteProvider(String id) {
_repository.deleteProvider(id); return _repository.deleteProvider(id).then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async { Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders(); final providersResult = await activeProviders();
@@ -186,6 +218,14 @@ class ProviderManager {
errorMessage: 'Search failed', errorMessage: 'Search failed',
), ),
); );
_scheduleSoftSearchRetry(
provider,
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
} }
} }
final posts = providerId == null final posts = providerId == null
@@ -228,6 +268,75 @@ class ProviderManager {
return hash; 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( Future<Result<List<PostComment>>> getComments(
String providerId, String providerId,
String postId, 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 { Future<Result<Post>> enrichPostTags(Post post) async {
final hasUsefulGroups = post.tagGroups.entries.any( final hasUsefulGroups = post.tagGroups.entries.any(
(entry) => entry.key != 'general' && entry.value.isNotEmpty, (entry) => entry.key != 'general' && entry.value.isNotEmpty,
@@ -313,29 +455,106 @@ class ProviderManager {
String query, { String query, {
int limit = 20, int limit = 20,
}) async { }) 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(); final providersResult = await activeFeedProviders();
if (providersResult is Error<List<ContentProvider>>) { if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure); return Error(providersResult.failure);
} }
final providers = (providersResult as Success<List<ContentProvider>>) final providers = <ContentProvider>[
.data for (final provider
.whereType<TagSuggestionProvider>() in (providersResult as Success<List<ContentProvider>>).data)
.toList(); if (provider is TagSuggestionProvider) provider,
final suggestions = <String, TagSuggestion>{}; ];
for (final provider in providers) { _suggestionProviderCache = _SuggestionProviderCacheEntry(
try { DateTime.now(),
final items = await provider.suggestTags(query, limit: limit); providers,
for (final item in items) { );
suggestions.putIfAbsent( return Success(providers);
'${item.providerId}:${item.name}', () => item); }
}
} catch (_) { void _clearSuggestionCaches() {
// Suggestions are non-critical; a failed provider should not affect UI. _suggestionCache.clear();
} _suggestionProviderCache = null;
}
final values = suggestions.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
return Success(values.take(limit).toList());
} }
Future<List<R>> _runLimited<T, R>( Future<List<R>> _runLimited<T, R>(
@@ -367,4 +586,44 @@ class ProviderManager {
static bool _isFeedConfig(ContentProviderConfig config) { static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(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, 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 @override
Future<List<Post>> searchPosts({ Future<List<Post>> searchPosts({
required List<String> tags, required List<String> tags,
@@ -111,8 +111,8 @@ class ProviderRepository {
id: 'realbooru', id: 'realbooru',
name: 'Realbooru', name: 'Realbooru',
baseUrl: 'https://realbooru.com', baseUrl: 'https://realbooru.com',
apiType: 'realbooru', apiType: 'realbooru_html',
enabled: false, enabled: true,
priority: 7, priority: 7,
timeoutSeconds: 20, timeoutSeconds: 20,
customHeaders: const {}, customHeaders: const {},
@@ -132,14 +132,16 @@ class ProviderRepository {
updatedAt: now, updatedAt: now,
), ),
ContentProviderConfig( ContentProviderConfig(
id: 'cosbooru', id: 'paheal',
name: 'CosBooru', name: 'Rule34 Paheal',
baseUrl: 'https://cos.lycore.co', baseUrl: 'https://rule34.paheal.net',
apiType: 'danbooru', apiType: 'paheal',
enabled: true, enabled: true,
priority: 9, priority: 9,
timeoutSeconds: 20, timeoutSeconds: 20,
customHeaders: const {}, customHeaders: const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
},
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
), ),
@@ -179,34 +181,12 @@ class ProviderRepository {
final existingProviders = final existingProviders =
await isar.providerConfigEntitys.where().findAll(); await isar.providerConfigEntitys.where().findAll();
for (final provider in existingProviders) { for (final provider in existingProviders) {
if (provider.providerId == 'paheal') { if (provider.providerId == 'cosbooru') {
await isar.providerConfigEntitys.delete(provider.isarId); 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(); final seeds = seedProviders();
for (final seed in seeds) { for (final seed in seeds) {
final exists = await isar.providerConfigEntitys final exists = await isar.providerConfigEntitys
@@ -216,13 +196,10 @@ class ProviderRepository {
if (exists == null) { if (exists == null) {
await isar.providerConfigEntitys await isar.providerConfigEntitys
.put(ProviderConfigEntity.fromModel(seed)); .put(ProviderConfigEntity.fromModel(seed));
} else if (seed.id == 'realbooru' && exists.enabled) { } else if (seed.id == 'realbooru' &&
exists.enabled = false; (exists.apiType != seed.apiType ||
exists.updatedAt = DateTime.now(); exists.baseUrl != seed.baseUrl ||
await isar.providerConfigEntitys.put(exists); exists.name != seed.name)) {
} else if (seed.id == 'cosbooru' &&
(exists.baseUrl != seed.baseUrl ||
exists.apiType != seed.apiType)) {
exists.name = seed.name; exists.name = seed.name;
exists.baseUrl = seed.baseUrl; exists.baseUrl = seed.baseUrl;
exists.apiType = seed.apiType; 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 { return _databaseService.safeRead((isar) async {
final items = await isar.searchHistoryEntitys.where().findAll(); final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); 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 DownloadedMediaService? _downloadedMediaService;
final _controller = StreamController<List<DownloadTask>>.broadcast(); final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {}; final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
bool _queuePaused = false;
Stream<List<DownloadTask>> get stream => _controller.stream; Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values); List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
bool get queuePaused => _queuePaused;
Future<DownloadTask> start(Post post) async { Future<DownloadTask> start(Post post) async {
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}'; final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
@@ -30,7 +33,7 @@ class DownloadManagerService {
status: DownloadTaskStatus.queued, status: DownloadTaskStatus.queued,
); );
_set(task); _set(task);
unawaited(_run(task)); _pumpQueue();
return task; return task;
} }
@@ -49,10 +52,21 @@ class DownloadManagerService {
openAfterDownload: openAfterDownload, openAfterDownload: openAfterDownload,
); );
_set(task); _set(task);
unawaited(_run(task)); _pumpQueue();
return task; return task;
} }
void pauseQueue() {
_queuePaused = true;
_emit();
}
void resumeQueue() {
_queuePaused = false;
_emit();
_pumpQueue();
}
Future<void> retry(String taskId) async { Future<void> retry(String taskId) async {
final existing = _tasks[taskId]; final existing = _tasks[taskId];
if (existing == null) return; if (existing == null) return;
@@ -62,7 +76,7 @@ class DownloadManagerService {
error: null, error: null,
); );
_set(task); _set(task);
await _run(task); _pumpQueue();
} }
void cancel(String taskId) { void cancel(String taskId) {
@@ -71,8 +85,19 @@ class DownloadManagerService {
_set(existing.copyWith(status: DownloadTaskStatus.canceled)); _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 { Future<void> _run(DownloadTask task) async {
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return; if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running)); _set(task.copyWith(status: DownloadTaskStatus.running));
try { try {
final saved = task.sourceUrl == null final saved = task.sourceUrl == null
@@ -121,14 +146,30 @@ class DownloadManagerService {
error: error.toString(), error: error.toString(),
), ),
); );
} finally {
_runningTaskIds.remove(task.id);
_pumpQueue();
} }
} }
void _set(DownloadTask task) { void _set(DownloadTask task) {
_tasks[task.id] = task; _tasks[task.id] = task;
_emit();
}
void _emit() {
_controller.add(tasks); _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) { void _updateProgress(String taskId, int received, int total) {
if (total <= 0) return; if (total <= 0) return;
final current = _tasks[taskId]; final current = _tasks[taskId];
@@ -143,7 +184,7 @@ class DownloadManagerService {
final current = _tasks[taskId]; final current = _tasks[taskId];
if (current?.status != DownloadTaskStatus.completed) return; if (current?.status != DownloadTaskStatus.completed) return;
_tasks.remove(taskId); _tasks.remove(taskId);
_controller.add(tasks); _emit();
}); });
} }
+34 -2
View File
@@ -28,7 +28,12 @@ class DownloadService {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName); 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', { final saved = await _channel.invokeMethod<String>('saveToDownloads', {
'path': tempPath, 'path': tempPath,
'fileName': fileName, 'fileName': fileName,
@@ -39,7 +44,12 @@ class DownloadService {
final location = await getSaveLocation(suggestedName: fileName); final location = await getSaveLocation(suggestedName: fileName);
if (location == null) return null; 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; return location.path;
} }
@@ -120,4 +130,26 @@ class DownloadService {
if (value.contains('.png') || value.contains('png')) return 'image/png'; if (value.contains('.png') || value.contains('png')) return 'image/png';
return 'image/jpeg'; 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); .toList(growable: false);
if (requested.isEmpty) return true; if (requested.isEmpty) return true;
final postTags = _postTagSet(post); 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) { 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); return _repository.recent(limit: limit);
} }
+36 -1
View File
@@ -41,6 +41,7 @@ class AppSettings {
required this.videoPlayerHalfVolume, required this.videoPlayerHalfVolume,
required this.videoPlayerLoop, required this.videoPlayerLoop,
required this.videoPlayerCover, required this.videoPlayerCover,
required this.videoPlaybackPositions,
required this.hiddenPostKeys, required this.hiddenPostKeys,
required this.diagnosticLogLines, required this.diagnosticLogLines,
required this.lastFeedTags, required this.lastFeedTags,
@@ -82,6 +83,7 @@ class AppSettings {
final bool videoPlayerHalfVolume; final bool videoPlayerHalfVolume;
final bool videoPlayerLoop; final bool videoPlayerLoop;
final bool videoPlayerCover; final bool videoPlayerCover;
final Map<String, int> videoPlaybackPositions;
final List<String> hiddenPostKeys; final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines; final List<String> diagnosticLogLines;
final List<String> lastFeedTags; final List<String> lastFeedTags;
@@ -108,7 +110,7 @@ class AppSettings {
mobileColumns: 2, mobileColumns: 2,
blurExplicitContent: true, blurExplicitContent: true,
allowDownloads: true, allowDownloads: true,
autoDownloadFavorites: false, autoDownloadFavorites: true,
selectedFeedProviderIds: [], selectedFeedProviderIds: [],
showPostBadges: true, showPostBadges: true,
defaultTopPeriodFilter: 'none', defaultTopPeriodFilter: 'none',
@@ -123,6 +125,7 @@ class AppSettings {
videoPlayerHalfVolume: false, videoPlayerHalfVolume: false,
videoPlayerLoop: false, videoPlayerLoop: false,
videoPlayerCover: false, videoPlayerCover: false,
videoPlaybackPositions: {},
hiddenPostKeys: [], hiddenPostKeys: [],
diagnosticLogLines: [], diagnosticLogLines: [],
lastFeedTags: [], lastFeedTags: [],
@@ -165,6 +168,7 @@ class AppSettings {
bool? videoPlayerHalfVolume, bool? videoPlayerHalfVolume,
bool? videoPlayerLoop, bool? videoPlayerLoop,
bool? videoPlayerCover, bool? videoPlayerCover,
Map<String, int>? videoPlaybackPositions,
List<String>? hiddenPostKeys, List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines, List<String>? diagnosticLogLines,
List<String>? lastFeedTags, List<String>? lastFeedTags,
@@ -212,6 +216,8 @@ class AppSettings {
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume, videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop, videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover, videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
videoPlaybackPositions:
videoPlaybackPositions ?? this.videoPlaybackPositions,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys, hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines, diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags, lastFeedTags: lastFeedTags ?? this.lastFeedTags,
@@ -256,6 +262,7 @@ class AppSettings {
'videoPlayerHalfVolume': videoPlayerHalfVolume, 'videoPlayerHalfVolume': videoPlayerHalfVolume,
'videoPlayerLoop': videoPlayerLoop, 'videoPlayerLoop': videoPlayerLoop,
'videoPlayerCover': videoPlayerCover, 'videoPlayerCover': videoPlayerCover,
'videoPlaybackPositions': videoPlaybackPositions,
'hiddenPostKeys': hiddenPostKeys, 'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines, 'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags, 'lastFeedTags': lastFeedTags,
@@ -334,6 +341,10 @@ class AppSettings {
(json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop, (json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop,
videoPlayerCover: videoPlayerCover:
(json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover, (json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover,
videoPlaybackPositions: Map<String, int>.from(
(json['videoPlaybackPositions'] as Map?) ??
defaults.videoPlaybackPositions,
),
hiddenPostKeys: List<String>.from( hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys, (json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
), ),
@@ -469,6 +480,30 @@ class SettingsService {
return updateSettings(settings.copyWith(hiddenPostKeys: const [])); 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 { Future<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings(); final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure); 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() { Future<Result<void>> clearHistory() {
return _repository.clear(); 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 '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart'; import 'favorites_controller.dart';
class FavoritesScreen extends ConsumerWidget { class FavoritesScreen extends ConsumerStatefulWidget {
const FavoritesScreen({super.key}); const FavoritesScreen({super.key});
@override @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 state = ref.watch(favoritesControllerProvider);
final settings = final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults; ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
@@ -33,28 +41,153 @@ class FavoritesScreen extends ConsumerWidget {
error: (error, _) => ErrorView(message: error.toString()), error: (error, _) => ErrorView(message: error.toString()),
data: (data) => data.posts.isEmpty data: (data) => data.posts.isEmpty
? const EmptyView(title: 'No favorites yet') ? const EmptyView(title: 'No favorites yet')
: PostMasonryGrid( : Column(
posts: data.posts, children: [
columns: Responsive.columnsFor( Padding(
context, padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
mobileColumns: settings.mobileColumns, child: SegmentedButton<int>(
desktopColumns: settings.desktopColumns, segments: const [
), ButtonSegment(
blurExplicit: settings.blurExplicitContent, value: 0,
showBadges: settings.showPostBadges, icon: Icon(Icons.favorite_rounded),
nsfwEnabled: settings.nsfwEnabled, label: Text('All'),
mediaQualityMode: ),
MediaQualityMode.fromName(settings.mediaQualityMode), ButtonSegment(
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(), value: 1,
downloadedKeys: downloaded.keys.toSet(), icon: Icon(Icons.offline_pin_rounded),
onOpen: (post) => context.push( label: Text('Offline'),
'/post/${post.providerId}/${post.id}', ),
extra: post, ButtonSegment(
), value: 2,
onFavorite: (post) => icon: Icon(Icons.groups_rounded),
ref.read(favoritesControllerProvider.notifier).remove(post), 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> { class FeedController extends AsyncNotifier<FeedState> {
Timer? _providerDebounce; Timer? _providerDebounce;
Timer? _suggestionDebounce;
StreamSubscription<ProviderSoftRetryResult>? _softRetrySubscription;
int _loadRequestId = 0; int _loadRequestId = 0;
int _suggestionRequestId = 0;
@override @override
Future<FeedState> build() async { 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 providers = await _loadProviders();
final settings = await _settings(); final settings = await _settings();
final providerIds = providers.map((provider) => provider.id).toSet(); final providerIds = providers.map((provider) => provider.id).toSet();
@@ -42,7 +53,13 @@ class FeedController extends AsyncNotifier<FeedState> {
state = const AsyncLoading(); state = const AsyncLoading();
final posts = await _load(refresh: true, current: current); final posts = await _load(refresh: true, current: current);
if (requestId != _loadRequestId) return; 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 { Future<void> refresh() async {
@@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier<FeedState> {
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true)); state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
final posts = await _load(refresh: true, current: current); final posts = await _load(refresh: true, current: current);
if (requestId != _loadRequestId) return; 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 { Future<void> loadNextPage() async {
@@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier<FeedState> {
if (current == null || current.isLoadingMore || !current.hasMore) return; if (current == null || current.isLoadingMore || !current.hasMore) return;
final requestId = ++_loadRequestId; final requestId = ++_loadRequestId;
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true)); 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; if (requestId != _loadRequestId) return;
final existingKeys = current.posts.map((post) => post.cacheKey).toSet(); final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
final newPosts = posts final newPosts = posts
.where((post) => existingKeys.add(post.cacheKey)) .where((post) => existingKeys.add(post.cacheKey))
.toList(growable: false); .toList(growable: false);
final emptyPageStreak =
newPosts.isEmpty ? current.emptyPageStreak + attempts : 0;
state = AsyncData( state = AsyncData(
current.copyWith( current.copyWith(
posts: [...current.posts, ...newPosts], posts: [...current.posts, ...newPosts],
isLoadingMore: false, 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 tags = ref.read(searchServiceProvider).parseTags(query);
final current = state.value ?? const FeedState(); final current = state.value ?? const FeedState();
state = AsyncData( state = AsyncData(
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), current.copyWith(
selectedTags: tags,
posts: [],
tagSuggestions: [],
hasMore: true,
emptyPageStreak: 0,
),
); );
await saveSession(scrollOffset: 0); await saveSession(scrollOffset: 0);
await ref.read(searchServiceProvider).saveSearch(query, 0); await ref.read(searchServiceProvider).saveSearch(query, 0);
@@ -89,31 +128,42 @@ class FeedController extends AsyncNotifier<FeedState> {
Future<void> updateTagSuggestions(String query) async { Future<void> updateTagSuggestions(String query) async {
final current = state.value ?? const FeedState(); 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: [])); state = AsyncData(current.copyWith(tagSuggestions: []));
return; return;
} }
final lastToken = query.trim().split(RegExp(r'\s+')).last; _suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
final result = await ref final result = await ref
.read(searchServiceProvider) .read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16); .autocompleteDetailed(lastToken, limit: 16);
final normalizedToken = lastToken.trim().toLowerCase(); if (requestId != _suggestionRequestId) return;
final suggestions = result is Success<List<TagSuggestion>> final normalizedToken = lastToken.trim().toLowerCase();
? result.data final suggestions = result is Success<List<TagSuggestion>>
.where((item) => item.name.toLowerCase().startsWith( ? result.data
normalizedToken, .where((item) => item.name.toLowerCase().startsWith(
)) normalizedToken,
.toList(growable: false) ))
: const <TagSuggestion>[]; .toList(growable: false)
state = AsyncData(current.copyWith(tagSuggestions: suggestions)); : const <TagSuggestion>[];
final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
});
} }
Future<void> setProviders(List<String> providerIds) async { Future<void> setProviders(List<String> providerIds) async {
final current = state.value ?? const FeedState(); final current = state.value ?? const FeedState();
final enabledIds = current.providers.map((provider) => provider.id).toSet(); final enabledIds = current.providers.map((provider) => provider.id).toSet();
final selected = providerIds.where(enabledIds.contains).toList(); final selected = providerIds.where(enabledIds.contains).toList();
state = state = AsyncData(current.copyWith(
AsyncData(current.copyWith(selectedProviderIds: selected, posts: [])); selectedProviderIds: selected,
posts: [],
hasMore: true,
emptyPageStreak: 0,
));
final settings = await _settings(); final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings( await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith( settings.copyWith(
@@ -129,7 +179,12 @@ class FeedController extends AsyncNotifier<FeedState> {
Future<void> setTopPeriod(TopPeriodFilter period) async { Future<void> setTopPeriod(TopPeriodFilter period) async {
final current = state.value ?? const FeedState(); 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(); final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings( await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(defaultTopPeriodFilter: period.name), settings.copyWith(defaultTopPeriodFilter: period.name),
@@ -142,7 +197,12 @@ class FeedController extends AsyncNotifier<FeedState> {
final current = state.value ?? const FeedState(); final current = state.value ?? const FeedState();
state = AsyncData( state = AsyncData(
current.copyWith( current.copyWith(
ratingFilter: rating, clearRating: rating == null, posts: []), ratingFilter: rating,
clearRating: rating == null,
posts: [],
hasMore: true,
emptyPageStreak: 0,
),
); );
await refresh(); await refresh();
await saveSession(scrollOffset: 0); await saveSession(scrollOffset: 0);
@@ -157,6 +217,8 @@ class FeedController extends AsyncNotifier<FeedState> {
tagSuggestions: [], tagSuggestions: [],
clearRating: true, clearRating: true,
posts: [], posts: [],
hasMore: true,
emptyPageStreak: 0,
), ),
); );
final settings = await _settings(); final settings = await _settings();
@@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier<FeedState> {
return posts; 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) { int _mixKey(Post post) {
var hash = 0x811c9dc5; var hash = 0x811c9dc5;
for (final code in post.cacheKey.codeUnits) { for (final code in post.cacheKey.codeUnits) {
@@ -1,3 +1,4 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,20 +29,24 @@ class FeedScreen extends ConsumerStatefulWidget {
ConsumerState<FeedScreen> createState() => _FeedScreenState(); ConsumerState<FeedScreen> createState() => _FeedScreenState();
} }
class _FeedScreenState extends ConsumerState<FeedScreen> { class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final Set<String> _selectedKeys = {}; final Set<String> _selectedKeys = {};
bool _selectionMode = false; bool _selectionMode = false;
String? _appliedInitialQuery; String? _appliedInitialQuery;
Timer? _scrollSaveDebounce; Timer? _scrollSaveDebounce;
double _lastKnownScrollOffset = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollController.addListener(() { _scrollController.addListener(() {
if (_scrollController.position.extentAfter < 800) { if (_scrollController.position.extentAfter < 800) {
ref.read(feedControllerProvider.notifier).loadNextPage(); ref.read(feedControllerProvider.notifier).loadNextPage();
} }
_lastKnownScrollOffset = _scrollController.offset;
_scrollSaveDebounce?.cancel(); _scrollSaveDebounce?.cancel();
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () { _scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
ref ref
@@ -53,6 +58,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
final settings = final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults; ref.read(appSettingsProvider).value ?? AppSettings.defaults;
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) { if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
_lastKnownScrollOffset = settings.lastFeedScrollOffset;
_scrollController.jumpTo( _scrollController.jumpTo(
settings.lastFeedScrollOffset.clamp( settings.lastFeedScrollOffset.clamp(
0.0, 0.0,
@@ -80,11 +86,32 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollSaveDebounce?.cancel(); _scrollSaveDebounce?.cancel();
_scrollController.dispose(); _scrollController.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final feed = ref.watch(feedControllerProvider); final feed = ref.watch(feedControllerProvider);
@@ -162,6 +189,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
tagSuggestions: state.tagSuggestions, tagSuggestions: state.tagSuggestions,
providers: state.providers, providers: state.providers,
rating: state.ratingFilter, rating: state.ratingFilter,
providerStatusMessage: state.providerStatusMessage,
onSearchChanged: (query) => ref onSearchChanged: (query) => ref
.read(feedControllerProvider.notifier) .read(feedControllerProvider.notifier)
.updateTagSuggestions(query), .updateTagSuggestions(query),
@@ -173,6 +201,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
onRefresh: () => onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(), ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: _clearFilters, onClearFilters: _clearFilters,
onRandom: () => _openRandom(state.posts),
selectionMode: _selectionMode, selectionMode: _selectionMode,
onToggleSelectionMode: () { onToggleSelectionMode: () {
setState(() { setState(() {
@@ -228,6 +257,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
.read(feedControllerProvider.notifier) .read(feedControllerProvider.notifier)
.refresh(), .refresh(),
child: PostMasonryGrid( child: PostMasonryGrid(
key: const PageStorageKey('feed_masonry_grid'),
controller: _scrollController, controller: _scrollController,
posts: state.posts, posts: state.posts,
columns: Responsive.columnsFor( columns: Responsive.columnsFor(
@@ -296,6 +326,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
context.go(location); 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 { Future<void> _applySearchQuery(String query) async {
await ref.read(feedControllerProvider.notifier).search(query); await ref.read(feedControllerProvider.notifier).search(query);
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
@@ -11,7 +11,9 @@ class FeedState {
this.providers = const [], this.providers = const [],
this.isLoadingMore = false, this.isLoadingMore = false,
this.hasMore = true, this.hasMore = true,
this.emptyPageStreak = 0,
this.error, this.error,
this.providerStatusMessage,
}); });
final List<Post> posts; final List<Post> posts;
@@ -23,7 +25,9 @@ class FeedState {
final List<ContentProviderConfig> providers; final List<ContentProviderConfig> providers;
final bool isLoadingMore; final bool isLoadingMore;
final bool hasMore; final bool hasMore;
final int emptyPageStreak;
final String? error; final String? error;
final String? providerStatusMessage;
FeedState copyWith({ FeedState copyWith({
List<Post>? posts, List<Post>? posts,
@@ -36,8 +40,11 @@ class FeedState {
List<ContentProviderConfig>? providers, List<ContentProviderConfig>? providers,
bool? isLoadingMore, bool? isLoadingMore,
bool? hasMore, bool? hasMore,
int? emptyPageStreak,
String? error, String? error,
bool clearError = false, bool clearError = false,
String? providerStatusMessage,
bool clearProviderStatus = false,
}) { }) {
return FeedState( return FeedState(
posts: posts ?? this.posts, posts: posts ?? this.posts,
@@ -49,7 +56,11 @@ class FeedState {
providers: providers ?? this.providers, providers: providers ?? this.providers,
isLoadingMore: isLoadingMore ?? this.isLoadingMore, isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore, hasMore: hasMore ?? this.hasMore,
emptyPageStreak: emptyPageStreak ?? this.emptyPageStreak,
error: clearError ? null : error ?? this.error, 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.onSuggestionTap,
required this.onTopPeriodChanged, required this.onTopPeriodChanged,
this.onToggleSelectionMode, this.onToggleSelectionMode,
this.onRandom,
this.providerStatusMessage,
this.selectionMode = false, this.selectionMode = false,
super.key, super.key,
}); });
@@ -43,6 +45,8 @@ class FeedToolbar extends StatefulWidget {
final ValueChanged<String> onSuggestionTap; final ValueChanged<String> onSuggestionTap;
final ValueChanged<TopPeriodFilter> onTopPeriodChanged; final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
final VoidCallback? onToggleSelectionMode; final VoidCallback? onToggleSelectionMode;
final VoidCallback? onRandom;
final String? providerStatusMessage;
final bool selectionMode; final bool selectionMode;
@override @override
@@ -122,9 +126,24 @@ class _FeedToolbarState extends State<FeedToolbar> {
onPressed: widget.onRefresh, onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
IconButton.filledTonal(
tooltip: 'Random post',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
], ],
), ),
const SizedBox(height: 8), 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( _HorizontalWheelScroller(
height: 36, height: 36,
builder: (controller) => ListView( builder: (controller) => ListView(
@@ -187,6 +206,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
}, },
), ),
const SizedBox(height: 8), 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( _HorizontalWheelScroller(
height: 40, height: 40,
builder: (controller) => ListView( builder: (controller) => ListView(
@@ -219,6 +248,11 @@ class _FeedToolbarState extends State<FeedToolbar> {
onPressed: widget.onRefresh, onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
IconButton.filledTonal(
tooltip: 'Random post',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
const SizedBox(width: 8), const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[ if (widget.providers.isNotEmpty) ...[
FilterChip( FilterChip(
@@ -3,17 +3,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart'; import '../../../backend/backend.dart';
import '../../../core/utils/result.dart'; import '../../../core/utils/result.dart';
// Меняем FutureProvider на StreamProvider // Stream the fastest available post version first, then enrich it.
final postDetailsControllerProvider = final postDetailsControllerProvider =
StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* { StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* {
// Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
final providerManager = ref.watch(providerManagerProvider); final providerManager = ref.watch(providerManagerProvider);
final cacheService = ref.watch(cacheServiceProvider); final cacheService = ref.watch(cacheServiceProvider);
final postRepository = ref.watch(postRepositoryProvider); 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 { Future<Post?> enrich(Post? post) async {
if (post == null) return null; 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 || final shouldEnrich = post.tagGroups.isEmpty ||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general')); (post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
if (!shouldEnrich) return post; if (!shouldEnrich) return post;
@@ -26,26 +42,29 @@ final postDetailsControllerProvider =
return post; return post;
} }
// СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай) // Scenario 1: feed navigation already passed the post.
if (args.initialPost != null) { if (args.initialPost != null) {
yield args if (!needsRealbooruDetails(args.initialPost!)) {
.initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится! yield args.initialPost;
}
final enriched = await enrich(args.initialPost); final enriched = await enrich(args.initialPost);
yield enriched; // Чуть позже отдаем версию с загруженными тегами yield enriched;
return; return;
} }
// СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш // Scenario 2: direct navigation can use the local cache first.
final cached = final cached =
await postRepository.getCachedPost(args.postId, args.providerId); await postRepository.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) { if (cached is Success<Post?> && cached.data != null) {
yield cached.data; // Нашли в кэше — сразу показываем контент if (!needsRealbooruDetails(cached.data!)) {
yield cached.data;
}
final enriched = await enrich(cached.data); final enriched = await enrich(cached.data);
yield enriched; // Догружаем теги на фоне yield enriched;
return; return;
} }
// СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом // Scenario 3: fall back to the provider when nothing local is available.
final remote = await providerManager.getPost(args.providerId, args.postId); final remote = await providerManager.getPost(args.providerId, args.postId);
if (remote is Success<Post?> && remote.data != null) { if (remote is Success<Post?> && remote.data != null) {
yield remote.data; yield remote.data;
@@ -94,7 +94,9 @@ class PostDetailsScreen extends ConsumerWidget {
return _MobilePostPager( return _MobilePostPager(
posts: feedPosts, posts: feedPosts,
initialIndex: currentIndex, initialIndex: currentIndex,
buildDetails: (context, post) => _buildMobileDetails( buildDetails: (context, post, mediaGestureLocked,
onMediaGestureLockChanged) =>
_buildMobileDetails(
context, context,
ref, ref,
post, post,
@@ -102,6 +104,8 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys, favoriteKeys,
qualityMode, qualityMode,
strings, strings,
mediaGestureLocked,
onMediaGestureLockChanged,
), ),
); );
} }
@@ -113,6 +117,8 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys, favoriteKeys,
qualityMode, qualityMode,
strings, strings,
false,
null,
); );
} }
return Shortcuts( return Shortcuts(
@@ -186,17 +192,29 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
Expanded( Expanded(
child: Center( child: Center(
child: ConstrainedBox( child: SizedBox(
constraints: const BoxConstraints(maxHeight: 760), width: double.infinity,
height: 760,
child: PostMediaViewer( child: PostMediaViewer(
key: ValueKey(post.cacheKey), key: ValueKey(post.cacheKey),
post: post, post: post,
qualityMode: qualityMode, qualityMode: qualityMode,
mediaHeaders: ref
.watch(postMediaHeadersProvider(post))
.value ??
const {},
initialPosition: Duration(
milliseconds: settings.videoPlaybackPositions[
post.cacheKey] ??
0,
),
initialLoop: settings.videoPlayerLoop, initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted, initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover, initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: initialHalfVolume:
settings.videoPlayerHalfVolume, settings.videoPlayerHalfVolume,
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) => onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot), _saveVideoPreferences(ref, snapshot),
), ),
@@ -233,6 +251,7 @@ class PostDetailsScreen extends ConsumerWidget {
_toggleFavorite(ref, post, favoriteKeys), _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post), onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)), onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () => onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)), Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post), onSimilar: () => _openSimilar(context, ref, post),
@@ -284,11 +303,14 @@ class PostDetailsScreen extends ConsumerWidget {
Set<String> favoriteKeys, Set<String> favoriteKeys,
MediaQualityMode qualityMode, MediaQualityMode qualityMode,
AppStrings strings, AppStrings strings,
bool mediaGestureLocked,
ValueChanged<bool>? onMediaGestureLockChanged,
) { ) {
final isVideo = MediaUrlSelector.isVideo(post); final isVideo = MediaUrlSelector.isVideo(post);
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) { onVerticalDragEnd: (details) {
if (mediaGestureLocked) return;
if ((details.primaryVelocity ?? 0) > 900) _close(context); if ((details.primaryVelocity ?? 0) > 900) _close(context);
}, },
child: ListView( child: ListView(
@@ -302,22 +324,30 @@ class PostDetailsScreen extends ConsumerWidget {
? null ? null
: () => : () =>
_showMobileQuickActions(context, ref, post, favoriteKeys), _showMobileQuickActions(context, ref, post, favoriteKeys),
child: ConstrainedBox( child: SizedBox(
constraints: BoxConstraints( width: double.infinity,
maxHeight: MediaQuery.sizeOf(context).height * 0.62, height: MediaQuery.sizeOf(context).height * 0.62,
), child: PostMediaViewer(
child: Center( key: ValueKey(post.cacheKey),
child: PostMediaViewer( post: post,
key: ValueKey(post.cacheKey), qualityMode: qualityMode,
post: post, mediaHeaders:
qualityMode: qualityMode, ref.watch(postMediaHeadersProvider(post)).value ?? const {},
initialLoop: settings.videoPlayerLoop, initialPosition: Duration(
initialMuted: settings.videoPlayerMuted, milliseconds:
initialCoverVideo: settings.videoPlayerCover, settings.videoPlaybackPositions[post.cacheKey] ?? 0,
initialHalfVolume: settings.videoPlayerHalfVolume,
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
), ),
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), onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post), onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)), onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post), onSimilar: () => _openSimilar(context, ref, post),
onHide: () => _hidePost(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); 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) { void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post); context.replace('/post/${post.providerId}/${post.id}', extra: post);
} }
@@ -580,6 +623,18 @@ class PostDetailsScreen extends ConsumerWidget {
); );
ref.invalidate(appSettingsProvider); 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 { class _MobilePostPager extends StatefulWidget {
@@ -591,19 +646,25 @@ class _MobilePostPager extends StatefulWidget {
final List<Post> posts; final List<Post> posts;
final int initialIndex; 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 @override
State<_MobilePostPager> createState() => _MobilePostPagerState(); State<_MobilePostPager> createState() => _MobilePostPagerState();
} }
class _MobilePostPagerState extends State<_MobilePostPager> { class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller; late final PageController _controller;
bool _mediaGestureLocked = false;
@override
void initState() { @override
super.initState(); void initState() {
_controller = PageController(initialPage: widget.initialIndex); super.initState();
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex); _prefetchAround(widget.initialIndex);
}); });
@@ -613,11 +674,11 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
void didUpdateWidget(covariant _MobilePostPager oldWidget) { void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex && if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 && widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) { widget.initialIndex < widget.posts.length) {
if (_controller.hasClients) { if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex); _controller.jumpToPage(widget.initialIndex);
} }
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex); _prefetchAround(widget.initialIndex);
@@ -634,22 +695,50 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PageView.builder( return PageView.builder(
controller: _controller, controller: _controller,
itemCount: widget.posts.length, physics: _mediaGestureLocked
onPageChanged: (index) { ? const NeverScrollableScrollPhysics()
_prefetchAround(index); : const PageScrollPhysics(),
}, itemCount: widget.posts.length,
onPageChanged: (index) {
_prefetchAround(index);
},
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _KeepAlivePostPage( final initialPost = widget.posts[index];
child: KeyedSubtree( return Consumer(
key: ValueKey(widget.posts[index].cacheKey), builder: (context, ref, _) {
child: widget.buildDetails(context, widget.posts[index]), 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) { void _prefetchAround(int index) {
for (final offset in [-2, -1, 0, 1, 2]) { for (final offset in [-2, -1, 0, 1, 2]) {
final target = index + offset; final target = index + offset;
@@ -670,19 +759,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
} }
Map<String, String> _headersFor(Post post) { Map<String, String> _headersFor(Post post) {
return { return const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*', '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.onOpen,
required this.onCopy, required this.onCopy,
required this.isFavorite, required this.isFavorite,
this.onOpenSource,
this.onDownload, this.onDownload,
this.onDeleteLocalFile, this.onDeleteLocalFile,
this.downloaded = false, this.downloaded = false,
@@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget {
final VoidCallback onCollection; final VoidCallback onCollection;
final VoidCallback onOpen; final VoidCallback onOpen;
final VoidCallback onCopy; final VoidCallback onCopy;
final VoidCallback? onOpenSource;
final bool isFavorite; final bool isFavorite;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile; final VoidCallback? onDeleteLocalFile;
@@ -58,6 +60,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onOpen, onPressed: onOpen,
icon: const Icon(Icons.open_in_new_rounded), 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( IconButton.filledTonal(
tooltip: text.copyLink, tooltip: text.copyLink,
onPressed: onCopy, onPressed: onCopy,
@@ -95,6 +103,7 @@ class PostActionLabels {
this.collection = 'Collection', this.collection = 'Collection',
this.similar = 'Similar', this.similar = 'Similar',
this.openOriginal = 'Open original', this.openOriginal = 'Open original',
this.openSource = 'Open source/page',
this.copyLink = 'Copy link', this.copyLink = 'Copy link',
this.download = 'Download', this.download = 'Download',
this.deleteLocalFile = 'Delete local file', this.deleteLocalFile = 'Delete local file',
@@ -106,6 +115,7 @@ class PostActionLabels {
final String collection; final String collection;
final String similar; final String similar;
final String openOriginal; final String openOriginal;
final String openSource;
final String copyLink; final String copyLink;
final String download; final String download;
final String deleteLocalFile; final String deleteLocalFile;
File diff suppressed because it is too large Load Diff
@@ -89,7 +89,9 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
DropdownMenuItem( DropdownMenuItem(
value: 'rule34', child: Text('rule34-compatible')), value: 'rule34', child: Text('rule34-compatible')),
DropdownMenuItem( DropdownMenuItem(
value: 'realbooru', child: Text('realbooru-compatible')), value: 'paheal', child: Text('Rule34 Paheal HTML')),
DropdownMenuItem(
value: 'realbooru_html', child: Text('realbooru HTML')),
DropdownMenuItem( DropdownMenuItem(
value: 'danbooru', child: Text('danbooru-compatible')), value: 'danbooru', child: Text('danbooru-compatible')),
DropdownMenuItem( DropdownMenuItem(
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart'; import '../../../backend/backend.dart';
@@ -8,8 +10,12 @@ final searchControllerProvider =
AsyncNotifierProvider<SearchController, SearchState>(SearchController.new); AsyncNotifierProvider<SearchController, SearchState>(SearchController.new);
class SearchController extends AsyncNotifier<SearchState> { class SearchController extends AsyncNotifier<SearchState> {
Timer? _suggestionDebounce;
int _suggestionRequestId = 0;
@override @override
Future<SearchState> build() async { Future<SearchState> build() async {
ref.onDispose(() => _suggestionDebounce?.cancel());
final recent = await _recent(); final recent = await _recent();
return SearchState(recent: recent); return SearchState(recent: recent);
} }
@@ -17,18 +23,42 @@ class SearchController extends AsyncNotifier<SearchState> {
Future<void> updateQuery(String query) async { Future<void> updateQuery(String query) async {
final lastToken = final lastToken =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final suggestionsResult = await ref final requestId = ++_suggestionRequestId;
.read(searchServiceProvider) _suggestionDebounce?.cancel();
.autocompleteDetailed(lastToken, limit: 16); if (lastToken.isEmpty) {
final suggestions = suggestionsResult is Success<List<TagSuggestion>> state = AsyncData(
? suggestionsResult.data (state.value ?? const SearchState()).copyWith(
: <TagSuggestion>[]; query: query,
suggestions: const [],
),
);
return;
}
state = AsyncData( state = AsyncData(
(state.value ?? const SearchState()).copyWith( (state.value ?? const SearchState()).copyWith(
query: query, 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 { Future<void> clearHistory() async {
@@ -2,24 +2,45 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
class RecentSearches extends StatelessWidget { class RecentSearches extends StatefulWidget {
const RecentSearches({required this.items, required this.onTap, super.key}); const RecentSearches({required this.items, required this.onTap, super.key});
final List<SearchHistory> items; final List<SearchHistory> items;
final ValueChanged<String> onTap; final ValueChanged<String> onTap;
@override
State<RecentSearches> createState() => _RecentSearchesState();
}
class _RecentSearchesState extends State<RecentSearches> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { 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( return Column(
children: [ children: [
for (final item in items) for (final item in visibleItems)
ListTile( ListTile(
leading: const Icon(Icons.history_rounded), leading: const Icon(Icons.history_rounded),
title: Text(item.query), title: Text(item.query),
subtitle: subtitle:
Text('${item.tags.join(' ')} · ${item.resultCount} results'), Text('${item.tags.join(' ')} - ${item.resultCount} results'),
onTap: () => onTap(item.query), 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( SwitchListTile(
value: settings.autoDownloadFavorites, value: settings.autoDownloadFavorites,
title: Text(strings.autoDownloadFavorites), title: Text(settings.languageCode == 'ru'
subtitle: Text(strings.autoDownloadFavoritesHint), ? '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( onChanged: (value) => _update(
ref, ref,
settings.copyWith(autoDownloadFavorites: value), settings.copyWith(autoDownloadFavorites: value),
@@ -283,6 +287,13 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.history_toggle_off_rounded), icon: const Icon(Icons.history_toggle_off_rounded),
label: Text(strings.clearViewed), 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( FilledButton.tonalIcon(
onPressed: () => ref onPressed: () => ref
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
@@ -741,13 +752,19 @@ class _ColorSwatches extends StatelessWidget {
final int selected; final int selected;
final ValueChanged<int> onChanged; final ValueChanged<int> onChanged;
static const colors = <int>[ static const colors = <(int, String)>[
0xFFE84D8A, (0xFFE84D8A, 'Lunaris pink'),
0xFF8B5CF6, (0xFF8B5CF6, 'Violet'),
0xFF3B82F6, (0xFF3B82F6, 'Azure'),
0xFF14B8A6, (0xFF0EA5E9, 'Sky'),
0xFFF97316, (0xFF14B8A6, 'Teal'),
0xFFEF4444, (0xFF22C55E, 'Green'),
(0xFFEAB308, 'Amber'),
(0xFFF97316, 'Orange'),
(0xFFEF4444, 'Red'),
(0xFFEC4899, 'Hot pink'),
(0xFF6366F1, 'Indigo'),
(0xFF64748B, 'Slate'),
]; ];
@override @override
@@ -761,28 +778,28 @@ class _ColorSwatches extends StatelessWidget {
spacing: 10, spacing: 10,
runSpacing: 10, runSpacing: 10,
children: [ children: [
for (final value in colors) for (final entry in colors)
Tooltip( Tooltip(
message: message:
'#${value.toRadixString(16).substring(2).toUpperCase()}', '${entry.$2} #${entry.$1.toRadixString(16).substring(2).toUpperCase()}',
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
onTap: () => onChanged(value), onTap: () => onChanged(entry.$1),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Color(value), color: Color(entry.$1),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: selected == value color: selected == entry.$1
? Theme.of(context).colorScheme.onSurface ? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.outlineVariant, : Theme.of(context).colorScheme.outlineVariant,
width: selected == value ? 3 : 1, width: selected == entry.$1 ? 3 : 1,
), ),
), ),
child: SizedBox( child: SizedBox(
width: 38, width: 38,
height: 38, height: 38,
child: selected == value child: selected == entry.$1
? const Icon(Icons.check_rounded, color: Colors.white) ? const Icon(Icons.check_rounded, color: Colors.white)
: null, : null,
), ),
@@ -4,11 +4,13 @@ import '../../../backend/backend.dart';
import '../../../core/utils/result.dart'; import '../../../core/utils/result.dart';
final viewedControllerProvider = 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 @override
Future<List<Post>> build() => _load(); Future<List<ViewedTimelineGroup>> build() => _load();
Future<void> refresh() async { Future<void> refresh() async {
state = AsyncData(await _load()); state = AsyncData(await _load());
@@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier<List<Post>> {
state = const AsyncData([]); state = const AsyncData([]);
} }
Future<List<Post>> _load() async { Future<List<ViewedTimelineGroup>> _load() async {
final result = final result =
await ref.read(viewedHistoryServiceProvider).getViewedPosts(); await ref.read(viewedHistoryServiceProvider).getViewedPostEntries();
return result is Success<List<Post>> ? result.data : const []; 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 'package:go_router/go_router.dart';
import '../../../app/app.dart'; import '../../../app/app.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart'; import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../favorites/presentation/favorites_controller.dart'; import '../../favorites/presentation/favorites_controller.dart';
import 'viewed_controller.dart'; import 'viewed_controller.dart';
@@ -21,7 +19,6 @@ class ViewedScreen extends ConsumerWidget {
final settings = final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults; ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{}; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
return AdaptiveScaffold( return AdaptiveScaffold(
title: settings.languageCode == 'ru' ? 'История' : 'Viewed', title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
actions: [ actions: [
@@ -34,47 +31,111 @@ class ViewedScreen extends ConsumerWidget {
body: state.when( body: state.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()), error: (error, _) => ErrorView(message: error.toString()),
data: (posts) => posts.isEmpty data: (groups) => groups.isEmpty
? const EmptyView(title: 'No viewed posts yet') ? const EmptyView(title: 'No viewed posts yet')
: RefreshIndicator( : RefreshIndicator(
onRefresh: () => onRefresh: () =>
ref.read(viewedControllerProvider.notifier).refresh(), ref.read(viewedControllerProvider.notifier).refresh(),
child: PostMasonryGrid( child: ListView(
posts: posts, padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
columns: Responsive.columnsFor( children: [
context, for (final group in groups) ...[
mobileColumns: settings.mobileColumns, Padding(
desktopColumns: settings.desktopColumns, padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
), child: Text(
blurExplicit: settings.blurExplicitContent, group.label,
showBadges: settings.showPostBadges, style: Theme.of(context).textTheme.titleMedium,
nsfwEnabled: settings.nsfwEnabled, ),
mediaQualityMode: ),
MediaQualityMode.fromName(settings.mediaQualityMode), for (final item in group.items)
favoriteKeys: favoriteKeys, _ViewedPostTile(
viewedKeys: viewedKeys, post: item.post,
onOpen: (post) => context.push( viewedAt: item.viewedAt,
'/post/${post.providerId}/${post.id}', isFavorite: favoriteKeys.contains(item.post.cacheKey),
extra: post, onOpen: () => context.push(
), '/post/${item.post.providerId}/${item.post.id}',
onFavorite: (post) async { extra: item.post,
if (favoriteKeys.contains(post.cacheKey)) { ),
await ref onFavorite: () => _toggleFavorite(
.read(favoriteServiceProvider) ref,
.removeFavorite(post.id, post.providerId); item.post,
} else { settings,
await ref.read(favoriteServiceProvider).addFavorite( favoriteKeys,
post, ),
settings: settings, ),
downloadManager: ],
ref.read(downloadManagerServiceProvider), ],
);
}
ref.invalidate(favoriteKeysProvider);
},
), ),
), ),
), ),
); );
} }
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; OverlayEntry? _suggestionsOverlay;
Timer? _debounce; Timer? _debounce;
List<String> _tags = []; List<String> _tags = [];
String _lastExternalValue = '';
bool _localDirty = false;
@override @override
void initState() { void initState() {
@@ -62,15 +64,20 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode(); _focusNode = FocusNode();
_tagScrollController = ScrollController(); _tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged); _focusNode.addListener(_handleFocusChanged);
_setFromQuery(widget.initialValue ?? ''); _lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue);
} }
@override @override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) { void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
final next = widget.initialValue ?? ''; 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); _setFromQuery(next);
_lastExternalValue = next;
_localDirty = false;
} }
if (oldWidget.suggestions != widget.suggestions) { if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay(); _syncSuggestionsOverlay();
@@ -90,6 +97,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() { void _handleFocusChanged() {
if (!mounted) return; if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) {
final next = widget.initialValue ?? '';
if (next != _lastExternalValue) {
_setFromQuery(next);
_lastExternalValue = next;
}
}
setState(() {}); setState(() {});
_syncSuggestionsOverlay(); _syncSuggestionsOverlay();
} }
@@ -193,7 +207,11 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _syncSuggestionsOverlay() { 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(); _removeSuggestionsOverlay();
return; return;
} }
@@ -203,20 +221,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
if (overlay == null || renderBox == null || !renderBox.hasSize) return; if (overlay == null || renderBox == null || !renderBox.hasSize) return;
final size = renderBox.size; final size = renderBox.size;
if (_suggestionsOverlay == null) { _removeSuggestionsOverlay();
_suggestionsOverlay = OverlayEntry( _suggestionsOverlay = OverlayEntry(
builder: (context) => _TagSuggestionOverlay( builder: (context) => _TagSuggestionOverlay(
link: _layerLink, link: _layerLink,
width: size.width, width: size.width,
yOffset: size.height + 8, yOffset: size.height + 8,
suggestions: widget.suggestions, suggestions: suggestions,
onSelected: _applySuggestion, onSelected: _applySuggestion,
), ),
); );
overlay.insert(_suggestionsOverlay!); overlay.insert(_suggestionsOverlay!);
return;
}
_suggestionsOverlay?.markNeedsBuild();
} }
void _removeSuggestionsOverlay() { void _removeSuggestionsOverlay() {
@@ -268,6 +283,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _handleDraftChanged(String value) { void _handleDraftChanged(String value) {
_localDirty = true;
final composing = _controller.value.composing; final composing = _controller.value.composing;
if (!composing.isValid && if (!composing.isValid &&
value.isNotEmpty && value.isNotEmpty &&
@@ -286,6 +302,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _commitDraft(String value) { void _commitDraft(String value) {
final additions = _parseTags(value); final additions = _parseTags(value);
if (additions.isEmpty) return; if (additions.isEmpty) return;
_localDirty = true;
setState(() { setState(() {
for (final tag in additions) { for (final tag in additions) {
if (!_tags.contains(tag)) _tags.add(tag); if (!_tags.contains(tag)) _tags.add(tag);
@@ -299,11 +316,14 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_debounce?.cancel(); _debounce?.cancel();
_commitDraft(_controller.text); _commitDraft(_controller.text);
_removeSuggestionsOverlay(); _removeSuggestionsOverlay();
_lastExternalValue = _query;
_localDirty = false;
widget.onSubmitted(_query); widget.onSubmitted(_query);
} }
void _clear() { void _clear() {
_debounce?.cancel(); _debounce?.cancel();
_localDirty = true;
setState(() { setState(() {
_tags = []; _tags = [];
_controller.clear(); _controller.clear();
@@ -313,6 +333,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _editTag(String tag) { void _editTag(String tag) {
_localDirty = true;
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue( _controller.value = TextEditingValue(
@@ -325,6 +346,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _removeTag(String tag) { void _removeTag(String tag) {
_localDirty = true;
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
}); });
@@ -333,6 +355,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _applySuggestion(String suggestion) { void _applySuggestion(String suggestion) {
final draft = _controller.text.trim(); final draft = _controller.text.trim();
_localDirty = true;
setState(() { setState(() {
if (draft.isNotEmpty) { if (draft.isNotEmpty) {
final draftTags = _parseTags(draft); final draftTags = _parseTags(draft);
@@ -353,7 +376,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _notifyChanged() { void _notifyChanged() {
_localDirty = true;
widget.onChanged?.call(_query); widget.onChanged?.call(_query);
_syncSuggestionsOverlay();
} }
List<String> _parseTags(String query) { List<String> _parseTags(String query) {
@@ -372,6 +397,14 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
final all = [..._tags, if (draft.isNotEmpty) draft]; final all = [..._tags, if (draft.isNotEmpty) draft];
return all.join(' '); 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 { class _TagSuggestionOverlay extends StatelessWidget {
@@ -396,11 +429,14 @@ class _TagSuggestionOverlay extends StatelessWidget {
link: link, link: link,
showWhenUnlinked: false, showWhenUnlinked: false,
offset: Offset(0, yOffset), offset: Offset(0, yOffset),
child: SizedBox( child: Align(
width: width.clamp(280, 620).toDouble(), alignment: Alignment.topLeft,
child: _TagSuggestionDropdown( child: SizedBox(
suggestions: suggestions, width: width.clamp(280, 620).toDouble(),
onSelected: onSelected, child: _TagSuggestionDropdown(
suggestions: suggestions,
onSelected: onSelected,
),
), ),
), ),
), ),
@@ -420,62 +456,59 @@ class _TagSuggestionDropdown extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Align( return ConstrainedBox(
alignment: Alignment.centerLeft, constraints: const BoxConstraints(maxWidth: 620),
child: ConstrainedBox( child: Material(
constraints: const BoxConstraints(maxWidth: 620), elevation: 8,
child: Material( shadowColor: Colors.black.withValues(alpha: 0.18),
elevation: 8, color: scheme.surface,
shadowColor: Colors.black.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(16),
color: scheme.surface, clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(16), child: ListView.separated(
clipBehavior: Clip.antiAlias, shrinkWrap: true,
child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 6),
shrinkWrap: true, itemCount: suggestions.length > 8 ? 8 : suggestions.length,
padding: const EdgeInsets.symmetric(vertical: 6), separatorBuilder: (_, __) => Divider(
itemCount: suggestions.length > 8 ? 8 : suggestions.length, height: 1,
separatorBuilder: (_, __) => Divider( indent: 52,
height: 1, color: scheme.outlineVariant.withValues(alpha: 0.55),
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,
),
),
],
],
),
),
);
},
), ),
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 ? null
: FloatingActionButton.small( : FloatingActionButton.small(
heroTag: 'mobile-settings', heroTag: 'mobile-settings',
tooltip: ru ? 'Настройки' : 'Settings', tooltip: ru
? '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'
: 'Settings',
onPressed: () => context.go('/settings'), onPressed: () => context.go('/settings'),
child: const Icon(Icons.settings_rounded), child: const Icon(Icons.settings_rounded),
), ),
@@ -325,14 +327,15 @@ class _Destination {
String labelFor(bool ru) { String labelFor(bool ru) {
if (!ru) return label; if (!ru) return label;
return switch (id) { return switch (id) {
'feed' => 'Лента', 'feed' => '\u041b\u0435\u043d\u0442\u0430',
'search' => 'Поиск', 'search' => '\u041f\u043e\u0438\u0441\u043a',
'favorites' => 'Избранное', 'favorites' => '\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435',
'viewed' => 'История', 'viewed' => '\u0418\u0441\u0442\u043e\u0440\u0438\u044f',
'collections' => 'Коллекции', 'collections' => '\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438',
'artists' => 'Артисты', 'artists' => '\u0410\u0440\u0442\u0438\u0441\u0442\u044b',
'providers' => 'Провайдеры', 'providers' =>
'settings' => 'Настройки', '\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b',
'settings' => '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
_ => label, _ => label,
}; };
} }
@@ -340,8 +343,8 @@ class _Destination {
String mobileLabelFor(bool ru) { String mobileLabelFor(bool ru) {
if (!ru) return mobileLabel; if (!ru) return mobileLabel;
return switch (id) { return switch (id) {
'collections' => 'Доски', 'collections' => '\u0414\u043e\u0441\u043a\u0438',
'favorites' => 'Любимое', 'favorites' => '\u041b\u044e\u0431\u0438\u043c\u043e\u0435',
_ => labelFor(true), _ => 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:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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 '../../app/motion.dart';
import '../../backend/backend.dart'; import '../../backend/backend.dart';
import '../../core/utils/result.dart';
import 'blur_content.dart'; import 'blur_content.dart';
import 'loading_skeleton.dart'; import 'loading_skeleton.dart';
import 'rating_badge.dart'; import 'rating_badge.dart';
class PostCard extends StatefulWidget { class PostCard extends ConsumerStatefulWidget {
const PostCard({ const PostCard({
required this.post, required this.post,
required this.blurExplicit, required this.blurExplicit,
@@ -44,25 +51,40 @@ class PostCard extends StatefulWidget {
final bool selected; final bool selected;
@override @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; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post; final post = _resolvedPost ?? widget.post;
final sensitive = _isSensitive(post.rating); final sensitive = _isSensitive(post.rating);
final aspect = post.width > 0 && post.height > 0 final aspect = post.width > 0 && post.height > 0
? post.width / post.height ? post.width / post.height
: _fallbackAspect(post.fileType); : _fallbackAspect(post.fileType);
final mobile = MediaQuery.sizeOf(context).width < 700; final mobile = MediaQuery.sizeOf(context).width < 700;
final urls = MediaUrlSelector.feed( final urls = _feedUrls(post, mobile: mobile);
post,
mode: widget.mediaQualityMode,
mobile: mobile,
);
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first; final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
@@ -130,8 +152,21 @@ class _PostCardState extends State<PostCard> {
final dpr = MediaQuery.devicePixelRatioOf(context); final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800); (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( return CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
httpHeaders: headers,
memCacheWidth: cacheWidth, memCacheWidth: cacheWidth,
maxWidthDiskCache: cacheWidth, maxWidthDiskCache: cacheWidth,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -291,6 +326,236 @@ class _PostCardState extends State<PostCard> {
if (normalized.contains('gif')) return 1; if (normalized.contains('gif')) return 1;
return 0.72; 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 { class _FavoriteButton extends StatelessWidget {
+4 -12
View File
@@ -683,10 +683,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1064,10 +1064,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
time: time:
dependency: transitive dependency: transitive
description: description:
@@ -1252,14 +1252,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webview_windows:
dependency: "direct main"
description:
name: webview_windows
sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
-1
View File
@@ -31,7 +31,6 @@ dependencies:
file_selector: ^1.0.3 file_selector: ^1.0.3
path: ^1.9.0 path: ^1.9.0
media_kit_libs_android_video: ^1.3.8 media_kit_libs_android_video: ^1.3.8
webview_windows: ^0.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+8
View File
@@ -50,6 +50,14 @@ void main() {
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse); expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
}); });
test('requested search tags can match tag prefixes', () {
final post = _post(tags: ['zenless_zone_zero', 'ellen_joe']);
expect(postMatchesRequestedTags(post, ['zenless']), isTrue);
expect(postMatchesRequestedTags(post, ['zenless', 'ellen']), isTrue);
expect(postMatchesRequestedTags(post, ['zone']), isFalse);
});
test('hidden post keys are filtered locally', () { test('hidden post keys are filtered locally', () {
final post = _post(tags: ['touhou']); final post = _post(tags: ['touhou']);
final settings = AppSettings.defaults.copyWith( final settings = AppSettings.defaults.copyWith(
+22 -19
View File
@@ -38,9 +38,13 @@ void main() {
File('${Directory.current.path}${Platform.pathSeparator}isar.dll'); File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
if (Platform.isWindows && !copiedIsarDll.existsSync()) { if (Platform.isWindows && !copiedIsarDll.existsSync()) {
final localAppData = Platform.environment['LOCALAPPDATA']; final localAppData = Platform.environment['LOCALAPPDATA'];
final source = File( final bundled = File(
'${Directory.current.path}\\third_party\\isar_flutter_libs\\windows\\isar.dll',
);
final cached = File(
'$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll', '$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll',
); );
final source = bundled.existsSync() ? bundled : cached;
if (source.existsSync()) { if (source.existsSync()) {
await source.copy(copiedIsarDll.path); await source.copy(copiedIsarDll.path);
} }
@@ -85,26 +89,24 @@ void main() {
'e926', 'e926',
'realbooru', 'realbooru',
'xbooru', 'xbooru',
'cosbooru', 'paheal',
'kemono', 'kemono',
'coomer', 'coomer',
]); ]);
expect( final realbooru =
providers.singleWhere((provider) => provider.id == 'realbooru').enabled, providers.singleWhere((provider) => provider.id == 'realbooru');
isFalse, expect(realbooru.enabled, isTrue);
); expect(realbooru.apiType, 'realbooru_html');
expect( expect(realbooru.baseUrl, 'https://realbooru.com');
providers.singleWhere((provider) => provider.id == 'cosbooru').enabled, expect(providers.where((provider) => provider.id == 'cosbooru'), isEmpty);
isTrue,
);
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru'); final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
expect(xbooru.enabled, isTrue); expect(xbooru.enabled, isTrue);
expect(xbooru.baseUrl, 'https://xbooru.com'); expect(xbooru.baseUrl, 'https://xbooru.com');
expect(xbooru.apiType, 'gelbooru'); expect(xbooru.apiType, 'gelbooru');
final cosbooru = final paheal = providers.singleWhere((provider) => provider.id == 'paheal');
providers.singleWhere((provider) => provider.id == 'cosbooru'); expect(paheal.enabled, isTrue);
expect(cosbooru.baseUrl, 'https://cos.lycore.co'); expect(paheal.baseUrl, 'https://rule34.paheal.net');
expect(cosbooru.apiType, 'danbooru'); expect(paheal.apiType, 'paheal');
final kemono = providers.singleWhere((provider) => provider.id == 'kemono'); final kemono = providers.singleWhere((provider) => provider.id == 'kemono');
final coomer = providers.singleWhere((provider) => provider.id == 'coomer'); final coomer = providers.singleWhere((provider) => provider.id == 'coomer');
expect(kemono.enabled, isTrue); expect(kemono.enabled, isTrue);
@@ -113,7 +115,8 @@ void main() {
expect(coomer.apiType, 'coomer'); expect(coomer.apiType, 'coomer');
}); });
test('provider repository migrates old CosBooru config', () async { test('provider repository disables old CosBooru config without deleting it',
() async {
final repository = ProviderRepository(databaseService); final repository = ProviderRepository(databaseService);
final now = DateTime(2026); final now = DateTime(2026);
await repository.saveProvider(ContentProviderConfig( await repository.saveProvider(ContentProviderConfig(
@@ -134,10 +137,10 @@ void main() {
final cosbooru = final cosbooru =
providers.singleWhere((provider) => provider.id == 'cosbooru'); providers.singleWhere((provider) => provider.id == 'cosbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co'); expect(cosbooru.baseUrl, 'https://cos.booru.nl');
expect(cosbooru.apiType, 'danbooru'); expect(cosbooru.apiType, 'gelbooru');
expect(cosbooru.enabled, isTrue); expect(cosbooru.enabled, isFalse);
expect(cosbooru.priority, 9); expect(cosbooru.priority, 99);
}); });
test('settings saveEnabledProviders updates provider configs', () async { test('settings saveEnabledProviders updates provider configs', () async {
+123
View File
@@ -84,6 +84,7 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
this.posts, { this.posts, {
this.failSearch = false, this.failSearch = false,
this.suggestions = const [], this.suggestions = const [],
this.suggestionDelay = Duration.zero,
}); });
@override @override
@@ -95,6 +96,8 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
final List<Post> posts; final List<Post> posts;
final bool failSearch; final bool failSearch;
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
final Duration suggestionDelay;
int suggestionCalls = 0;
@override @override
Future<ProviderHealth> checkHealth() async => ProviderHealth( Future<ProviderHealth> checkHealth() async => ProviderHealth(
@@ -123,6 +126,10 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
@override @override
Future<List<TagSuggestion>> suggestTags(String query, Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async { {int limit = 20}) async {
suggestionCalls++;
if (suggestionDelay > Duration.zero) {
await Future<void>.delayed(suggestionDelay);
}
return suggestions return suggestions
.where((suggestion) => suggestion.name.startsWith(query)) .where((suggestion) => suggestion.name.startsWith(query))
.take(limit) .take(limit)
@@ -303,4 +310,120 @@ void main() {
'e926', 'e926',
}); });
}); });
test('tag suggestions timeout slow providers without blocking fast results',
() async {
final repository = FakeProviderRepository()
..configs['fast'] = config('fast', 0)
..configs['slow'] = config('slow', 1);
final fast = FakeProvider(
'fast',
'fast',
[],
suggestions: const [
TagSuggestion(
name: 'cat_fast',
category: TagCategory.general,
postCount: 20,
providerId: 'fast',
),
],
);
final slow = FakeProvider(
'slow',
'slow',
[],
suggestionDelay: const Duration(seconds: 3),
suggestions: const [
TagSuggestion(
name: 'cat_slow',
category: TagCategory.general,
postCount: 200,
providerId: 'slow',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'fast': fast, 'slow': slow}),
);
final startedAt = DateTime.now();
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(DateTime.now().difference(startedAt),
lessThan(const Duration(milliseconds: 2500)));
expect(result.data.map((item) => item.name), ['cat_fast']);
});
test('tag suggestions dedupe by name and keep highest post count', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
),
'b': FakeProvider(
'b',
'b',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.artist,
postCount: 100,
providerId: 'b',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data, hasLength(1));
expect(result.data.single.providerId, 'b');
expect(result.data.single.postCount, 100);
});
test('tag suggestions cache repeated prefix results', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final provider = FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'a': provider}),
);
await manager.suggestTags('cat', limit: 4);
await manager.suggestTags('cat', limit: 4);
expect(provider.suggestionCalls, 1);
});
} }
+21 -22
View File
@@ -5,10 +5,10 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart'; import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart'; import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/danbooru_provider.dart'; import 'package:gel_rule_app/backend/providers/custom_provider.dart';
import 'package:gel_rule_app/backend/providers/kemono_provider.dart'; import 'package:gel_rule_app/backend/providers/kemono_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart'; import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart'; import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
void main() { void main() {
@@ -231,31 +231,30 @@ void main() {
expect(posts.single.rating, 'unknown'); expect(posts.single.rating, 'unknown');
}); });
test('seed providers include CosBooru and no Paheal', () { test('seed providers include Realbooru HTML Paheal and exclude CosBooru', () {
final paheal = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'paheal')
.single;
expect(paheal.enabled, isTrue);
expect(paheal.apiType, 'paheal');
expect(paheal.baseUrl, 'https://rule34.paheal.net');
final realbooru = ProviderRepository.seedProviders() final realbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'realbooru') .where((provider) => provider.id == 'realbooru')
.single; .single;
expect(realbooru.enabled, isTrue);
expect(realbooru.enabled, isFalse); expect(realbooru.apiType, 'realbooru_html');
expect(realbooru.apiType, 'realbooru');
expect(realbooru.baseUrl, 'https://realbooru.com'); expect(realbooru.baseUrl, 'https://realbooru.com');
expect( expect(
ProviderRepository.seedProviders() ProviderRepository.seedProviders()
.where((provider) => provider.id == 'paheal'), .where((provider) => provider.id == 'cosbooru'),
isEmpty, isEmpty,
); );
final cosbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'cosbooru')
.single;
final xbooru = ProviderRepository.seedProviders() final xbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'xbooru') .where((provider) => provider.id == 'xbooru')
.single; .single;
expect(xbooru.enabled, isTrue); expect(xbooru.enabled, isTrue);
expect(xbooru.apiType, 'gelbooru'); expect(xbooru.apiType, 'gelbooru');
expect(xbooru.baseUrl, 'https://xbooru.com'); expect(xbooru.baseUrl, 'https://xbooru.com');
expect(cosbooru.enabled, isTrue);
expect(cosbooru.apiType, 'danbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
final kemono = ProviderRepository.seedProviders() final kemono = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'kemono') .where((provider) => provider.id == 'kemono')
.single; .single;
@@ -268,7 +267,7 @@ void main() {
expect(coomer.apiType, 'coomer'); expect(coomer.apiType, 'coomer');
}); });
test('provider factory creates Realbooru provider', () { test('provider factory treats Realbooru api type as unsupported', () {
final now = DateTime(2026); final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig( final provider = ProviderFactory().create(ContentProviderConfig(
id: 'realbooru', id: 'realbooru',
@@ -283,25 +282,25 @@ void main() {
updatedAt: now, updatedAt: now,
)); ));
expect(provider, isA<RealbooruProvider>()); expect(provider, isA<UnsupportedCustomProvider>());
}); });
test('provider factory creates CosBooru as Danbooru provider', () { test('provider factory creates Realbooru HTML provider', () {
final now = DateTime(2026); final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig( final provider = ProviderFactory().create(ContentProviderConfig(
id: 'cosbooru', id: 'realbooru',
name: 'CosBooru', name: 'Realbooru',
baseUrl: 'https://cos.lycore.co', baseUrl: 'https://realbooru.com',
apiType: 'danbooru', apiType: 'realbooru_html',
enabled: true, enabled: true,
priority: 8, priority: 7,
timeoutSeconds: 20, timeoutSeconds: 20,
customHeaders: const {}, customHeaders: const {},
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
)); ));
expect(provider, isA<DanbooruProvider>()); expect(provider, isA<RealbooruHtmlProvider>());
}); });
test('provider factory creates Kemono and Coomer artist providers', () { test('provider factory creates Kemono and Coomer artist providers', () {
+2 -2
View File
@@ -14,10 +14,10 @@ class FakeSearchRepository implements SearchRepository {
} }
@override @override
Future<Result<List<SearchHistory>>> recent({int limit = 20}) async { Future<Result<List<SearchHistory>>> recent({int? limit}) async {
final sorted = [...items] final sorted = [...items]
..sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); ..sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
return Success(sorted.take(limit).toList()); return Success((limit == null ? sorted : sorted.take(limit)).toList());
} }
@override @override
+113 -4
View File
@@ -99,6 +99,83 @@ void main() {
expect(find.text('new_tag'), findsOneWidget); expect(find.text('new_tag'), findsOneWidget);
}); });
testWidgets('old external value does not restore deleted chip',
(tester) async {
String? changed;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag keep_tag',
onSubmitted: (_) {},
onChanged: (value) {
changed = value;
},
),
));
await tester.tap(find.byIcon(Icons.close_rounded).first);
await tester.pump();
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag keep_tag',
onSubmitted: (_) {},
onChanged: (value) {
changed = value;
},
),
));
await tester.pump();
expect(find.text('old_tag'), findsNothing);
expect(find.text('keep_tag'), findsOneWidget);
expect(changed, 'keep_tag');
});
testWidgets('focused draft is not replaced by stale external value',
(tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag',
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'new_tag');
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag',
onSubmitted: (_) {},
),
));
await tester.pump();
expect(find.text('old_tag'), findsOneWidget);
expect(find.text('new_tag'), findsOneWidget);
});
testWidgets('matching external draft does not become a chip while focused',
(tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: '',
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'draft_tag');
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'draft_tag',
onSubmitted: (_) {},
),
));
await tester.pump();
expect(find.byType(InputChip), findsNothing);
expect(find.text('draft_tag'), findsOneWidget);
});
testWidgets('suggestion appends active tag as a chip', (tester) async { testWidgets('suggestion appends active tag as a chip', (tester) async {
String? applied; String? applied;
await tester.pumpWidget(_Harness( await tester.pumpWidget(_Harness(
@@ -118,8 +195,9 @@ void main() {
), ),
)); ));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'tou'); await tester.enterText(find.byType(TextField), 'tou');
await tester.pump(); await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('touhou')); await tester.tap(find.text('touhou'));
await tester.pump(); await tester.pump();
@@ -127,6 +205,36 @@ void main() {
expect(applied, 'touhou'); expect(applied, 'touhou');
}); });
testWidgets('stale suggestions are hidden for a newer active token',
(tester) async {
await tester.pumpWidget(_Harness(
child: SizedBox(
width: 360,
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'touhou',
category: TagCategory.copyright,
postCount: 1200,
providerId: 'gelbooru',
),
],
onSubmitted: (_) {},
),
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'tou');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('touhou'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'cat');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('touhou'), findsNothing);
});
testWidgets('suggestions overlay does not resize search bar', (tester) async { testWidgets('suggestions overlay does not resize search bar', (tester) async {
await tester.pumpWidget(_Harness( await tester.pumpWidget(_Harness(
child: SizedBox( child: SizedBox(
@@ -140,7 +248,7 @@ void main() {
providerId: 'gelbooru', providerId: 'gelbooru',
), ),
TagSuggestion( TagSuggestion(
name: 'tail', name: 'tou_tail',
category: TagCategory.general, category: TagCategory.general,
postCount: 900, postCount: 900,
providerId: 'e621', providerId: 'e621',
@@ -153,11 +261,12 @@ void main() {
final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height; final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height;
await tester.tap(find.byType(TextField)); await tester.tap(find.byType(TextField));
await tester.pump(); await tester.enterText(find.byType(TextField), 'tou');
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(); await tester.pump();
expect(find.text('touhou'), findsOneWidget); expect(find.text('touhou'), findsOneWidget);
expect(find.text('tail'), findsOneWidget); expect(find.text('tou_tail'), findsOneWidget);
expect( expect(
tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight); tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight);
}); });
@@ -13,7 +13,6 @@
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h> #include <volume_controller/volume_controller_plugin_c_api.h>
#include <webview_windows/webview_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
@@ -30,6 +29,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar( VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WebviewWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WebviewWindowsPlugin"));
} }
-1
View File
@@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_video media_kit_video
url_launcher_windows url_launcher_windows
volume_controller volume_controller
webview_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST