feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +646,12 @@ 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();
|
||||||
@@ -599,6 +659,7 @@ class _MobilePostPager extends StatefulWidget {
|
|||||||
|
|
||||||
class _MobilePostPagerState extends State<_MobilePostPager> {
|
class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||||
late final PageController _controller;
|
late final PageController _controller;
|
||||||
|
bool _mediaGestureLocked = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -635,21 +696,49 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PageView.builder(
|
return PageView.builder(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
physics: _mediaGestureLocked
|
||||||
|
? const NeverScrollableScrollPhysics()
|
||||||
|
: const PageScrollPhysics(),
|
||||||
itemCount: widget.posts.length,
|
itemCount: widget.posts.length,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
_prefetchAround(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;
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:webview_windows/webview_windows.dart';
|
|
||||||
|
|
||||||
import '../../../../app/motion.dart';
|
import '../../../../app/motion.dart';
|
||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
@@ -26,8 +24,10 @@ class PostMediaViewer extends StatefulWidget {
|
|||||||
this.initialCoverVideo = false,
|
this.initialCoverVideo = false,
|
||||||
this.initialHalfVolume = false,
|
this.initialHalfVolume = false,
|
||||||
this.qualityMode = MediaQualityMode.auto,
|
this.qualityMode = MediaQualityMode.auto,
|
||||||
|
this.mediaHeaders = const {},
|
||||||
this.onPlaybackSnapshot,
|
this.onPlaybackSnapshot,
|
||||||
this.onPlaybackPreferencesChanged,
|
this.onPlaybackPreferencesChanged,
|
||||||
|
this.onMediaGestureLockChanged,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,8 +40,10 @@ class PostMediaViewer extends StatefulWidget {
|
|||||||
final bool initialCoverVideo;
|
final bool initialCoverVideo;
|
||||||
final bool initialHalfVolume;
|
final bool initialHalfVolume;
|
||||||
final MediaQualityMode qualityMode;
|
final MediaQualityMode qualityMode;
|
||||||
|
final Map<String, String> mediaHeaders;
|
||||||
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
|
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
|
||||||
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackPreferencesChanged;
|
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackPreferencesChanged;
|
||||||
|
final ValueChanged<bool>? onMediaGestureLockChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PostMediaViewer> createState() => _PostMediaViewerState();
|
State<PostMediaViewer> createState() => _PostMediaViewerState();
|
||||||
@@ -66,6 +68,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
StreamSubscription<String>? _errorSubscription;
|
StreamSubscription<String>? _errorSubscription;
|
||||||
StreamSubscription<Duration>? _positionSubscription;
|
StreamSubscription<Duration>? _positionSubscription;
|
||||||
StreamSubscription<bool>? _playingSubscription;
|
StreamSubscription<bool>? _playingSubscription;
|
||||||
|
DateTime? _lastSnapshotEmitAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -110,11 +113,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
if (_isSwf(widget.post)) {
|
if (_isSwf(widget.post)) {
|
||||||
return _SwfMediaViewer(
|
return const _UnsupportedSwfPanel();
|
||||||
post: widget.post,
|
|
||||||
url: _swfUrl(widget.post),
|
|
||||||
fullscreen: widget.fullscreen,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_controller != null) {
|
if (_controller != null) {
|
||||||
@@ -202,34 +201,42 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
|
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
|
||||||
|
final headers = _headersFor(widget.post);
|
||||||
final image = CachedNetworkImage(
|
final image = CachedNetworkImage(
|
||||||
key: ValueKey(url),
|
key: ValueKey(url),
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
httpHeaders: _headersFor(widget.post),
|
httpHeaders: headers,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
placeholder: (context, url) =>
|
placeholder: (context, url) =>
|
||||||
const Center(child: CircularProgressIndicator()),
|
const Center(child: CircularProgressIndicator()),
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
if (_imageIndex < _imageUrls.length - 1) {
|
return _DioImageFallback(
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
imageUrl: url,
|
||||||
if (mounted) setState(() => _imageIndex++);
|
headers: headers,
|
||||||
});
|
fit: BoxFit.contain,
|
||||||
return const Center(child: CircularProgressIndicator());
|
onFailed: _advanceImageFallback,
|
||||||
}
|
|
||||||
return const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.broken_image_rounded, size: 48),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text('Could not load image'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
|
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
|
||||||
return allowPinchZoom ? _ZoomableImage(child: image) : image;
|
final child = allowPinchZoom
|
||||||
|
? _ZoomableImage(
|
||||||
|
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||||
|
child: image,
|
||||||
|
)
|
||||||
|
: image;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
|
||||||
|
return SizedBox(
|
||||||
|
width: constraints.maxWidth,
|
||||||
|
height: constraints.maxHeight,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializeVideo() {
|
void _initializeVideo() {
|
||||||
@@ -242,18 +249,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
);
|
);
|
||||||
_errorSubscription = _player!.stream.error.listen((message) {
|
_errorSubscription = _player!.stream.error.listen((message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_videoIndex < _videoUrls.length - 1) {
|
unawaited(_handleVideoError(message));
|
||||||
_videoIndex++;
|
|
||||||
_openVideo(play: false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _videoError = message);
|
|
||||||
_showControls();
|
|
||||||
});
|
});
|
||||||
_positionSubscription = _player!.stream.position.listen((_) {
|
_positionSubscription = _player!.stream.position.listen((_) {
|
||||||
final snapshot = _snapshot();
|
final snapshot = _snapshot();
|
||||||
_playbackMemory[widget.post.cacheKey] = snapshot;
|
_playbackMemory[widget.post.cacheKey] = snapshot;
|
||||||
widget.onPlaybackSnapshot?.call(snapshot);
|
_emitPlaybackSnapshotThrottled(snapshot);
|
||||||
});
|
});
|
||||||
_playingSubscription = _player!.stream.playing.listen((_) {
|
_playingSubscription = _player!.stream.playing.listen((_) {
|
||||||
final snapshot = _snapshot();
|
final snapshot = _snapshot();
|
||||||
@@ -271,18 +272,35 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
final initialPosition = widget.initialPosition > Duration.zero
|
final initialPosition = widget.initialPosition > Duration.zero
|
||||||
? widget.initialPosition
|
? widget.initialPosition
|
||||||
: remembered?.position ?? Duration.zero;
|
: remembered?.position ?? Duration.zero;
|
||||||
setState(() => _videoError = null);
|
final shouldPlay = play || (remembered?.playing ?? false);
|
||||||
await player.open(
|
try {
|
||||||
Media(
|
setState(() => _videoError = null);
|
||||||
_videoUrls[_videoIndex],
|
await player.stop();
|
||||||
httpHeaders: _headersFor(widget.post),
|
await player.open(
|
||||||
),
|
Media(
|
||||||
play: false,
|
_videoUrls[_videoIndex],
|
||||||
);
|
httpHeaders: _headersFor(widget.post),
|
||||||
if (initialPosition > Duration.zero) {
|
),
|
||||||
await player.seek(initialPosition);
|
play: false,
|
||||||
|
);
|
||||||
|
if (initialPosition > Duration.zero) {
|
||||||
|
await player.seek(initialPosition);
|
||||||
|
}
|
||||||
|
if (shouldPlay) await player.play();
|
||||||
|
} catch (error) {
|
||||||
|
await _handleVideoError(error.toString(), play: shouldPlay);
|
||||||
}
|
}
|
||||||
if (play) await player.play();
|
}
|
||||||
|
|
||||||
|
Future<void> _handleVideoError(String message, {bool play = false}) async {
|
||||||
|
if (_videoIndex < _videoUrls.length - 1) {
|
||||||
|
_videoIndex++;
|
||||||
|
await _openVideo(play: play);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _videoError = message);
|
||||||
|
_showControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _retryVideo() async {
|
Future<void> _retryVideo() async {
|
||||||
@@ -338,31 +356,21 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
return value.contains('swf') || value.contains('.swf');
|
return value.contains('swf') || value.contains('.swf');
|
||||||
}
|
}
|
||||||
|
|
||||||
String _swfUrl(Post post) {
|
|
||||||
for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) {
|
|
||||||
final value = url.trim();
|
|
||||||
if (value.toLowerCase().contains('.swf')) return value;
|
|
||||||
}
|
|
||||||
return post.fileUrl.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, String> _headersFor(Post post) {
|
Map<String, String> _headersFor(Post post) {
|
||||||
return {
|
return {
|
||||||
'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') ||
|
...widget.mediaHeaders,
|
||||||
post.fileUrl.contains('gelbooru.com') ||
|
|
||||||
post.sampleUrl.contains('gelbooru.com') ||
|
|
||||||
post.previewUrl.contains('gelbooru.com'))
|
|
||||||
'Referer': 'https://gelbooru.com/',
|
|
||||||
if (post.providerName.toLowerCase().contains('rule34') ||
|
|
||||||
post.fileUrl.contains('rule34') ||
|
|
||||||
post.sampleUrl.contains('rule34') ||
|
|
||||||
post.previewUrl.contains('rule34'))
|
|
||||||
'Referer': 'https://rule34.xxx/',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _advanceImageFallback() {
|
||||||
|
if (_imageIndex >= _imageUrls.length - 1) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _imageIndex++);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _showControls() {
|
void _showControls() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _controlsVisible = true);
|
setState(() => _controlsVisible = true);
|
||||||
@@ -455,10 +463,19 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
void _emitPlaybackPreferences() {
|
void _emitPlaybackPreferences() {
|
||||||
final snapshot = _snapshot();
|
final snapshot = _snapshot();
|
||||||
_playbackMemory[widget.post.cacheKey] = snapshot;
|
_playbackMemory[widget.post.cacheKey] = snapshot;
|
||||||
widget.onPlaybackSnapshot?.call(snapshot);
|
|
||||||
widget.onPlaybackPreferencesChanged?.call(snapshot);
|
widget.onPlaybackPreferencesChanged?.call(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _emitPlaybackSnapshotThrottled(VideoPlaybackSnapshot snapshot) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final last = _lastSnapshotEmitAt;
|
||||||
|
if (last != null && now.difference(last) < const Duration(seconds: 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastSnapshotEmitAt = now;
|
||||||
|
widget.onPlaybackSnapshot?.call(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _togglePlay() async {
|
Future<void> _togglePlay() async {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
@@ -475,264 +492,25 @@ class _TogglePlayIntent extends Intent {
|
|||||||
const _TogglePlayIntent();
|
const _TogglePlayIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SwfMediaViewer extends StatelessWidget {
|
class _UnsupportedSwfPanel extends StatelessWidget {
|
||||||
const _SwfMediaViewer({
|
const _UnsupportedSwfPanel();
|
||||||
required this.post,
|
|
||||||
required this.url,
|
|
||||||
required this.fullscreen,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Post post;
|
|
||||||
final String url;
|
|
||||||
final bool fullscreen;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (url.isEmpty) {
|
return const ColoredBox(
|
||||||
return const Center(child: Text('SWF URL is empty'));
|
|
||||||
}
|
|
||||||
if (Platform.isWindows) {
|
|
||||||
return _WindowsRuffleViewer(url: url, fullscreen: fullscreen);
|
|
||||||
}
|
|
||||||
return _SwfUnsupportedPanel(url: url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WindowsRuffleViewer extends StatefulWidget {
|
|
||||||
const _WindowsRuffleViewer({
|
|
||||||
required this.url,
|
|
||||||
required this.fullscreen,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String url;
|
|
||||||
final bool fullscreen;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> {
|
|
||||||
final _controller = WebviewController();
|
|
||||||
bool _loading = true;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.url != widget.url) {
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final child = ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
if (_controller.value.isInitialized) Webview(_controller),
|
|
||||||
if (_loading)
|
|
||||||
const ColoredBox(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
if (_error != null)
|
|
||||||
_SwfErrorPanel(
|
|
||||||
message: _error!,
|
|
||||||
url: widget.url,
|
|
||||||
onRetry: _load,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (widget.fullscreen) return child;
|
|
||||||
return AspectRatio(aspectRatio: 4 / 3, child: child);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
try {
|
|
||||||
await _controller.initialize();
|
|
||||||
_controller.loadingState.listen((state) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _loading = state == LoadingState.loading);
|
|
||||||
});
|
|
||||||
_controller.onLoadError.listen((error) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _error = 'Ruffle WebView error: $error');
|
|
||||||
});
|
|
||||||
await _load();
|
|
||||||
} catch (error) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error =
|
|
||||||
'Could not start Windows WebView2. Install WebView2 Runtime or open original.';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
if (!_controller.value.isInitialized) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await _controller.loadStringContent(_ruffleHtml(widget.url));
|
|
||||||
} catch (error) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = 'Could not load Ruffle viewer: $error';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _ruffleHtml(String swfUrl) {
|
|
||||||
final escapedUrl = _htmlEscape(swfUrl);
|
|
||||||
return '''
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
html, body { margin: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
|
||||||
#stage { width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
||||||
ruffle-player { width: 100%; height: 100%; background: #000; }
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
window.RufflePlayer = window.RufflePlayer || {};
|
|
||||||
window.RufflePlayer.config = {
|
|
||||||
autoplay: "on",
|
|
||||||
unmuteOverlay: "visible",
|
|
||||||
splashScreen: true,
|
|
||||||
letterbox: "on"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="https://unpkg.com/@ruffle-rs/ruffle"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="stage">
|
|
||||||
<embed src="$escapedUrl" width="100%" height="100%">
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _htmlEscape(String value) {
|
|
||||||
return value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SwfUnsupportedPanel extends StatelessWidget {
|
|
||||||
const _SwfUnsupportedPanel({required this.url});
|
|
||||||
|
|
||||||
final String url;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return _SwfErrorPanel(
|
|
||||||
message: Platform.isAndroid
|
|
||||||
? 'Flash/SWF playback is desktop only.'
|
|
||||||
: 'SWF playback is available on Windows only in this build.',
|
|
||||||
url: url,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SwfErrorPanel extends StatelessWidget {
|
|
||||||
const _SwfErrorPanel({
|
|
||||||
required this.message,
|
|
||||||
required this.url,
|
|
||||||
this.onRetry,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
final String url;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ColoredBox(
|
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(22),
|
padding: EdgeInsets.all(22),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.extension_rounded,
|
Icon(Icons.extension_off_rounded, color: Colors.white, size: 48),
|
||||||
color: Colors.white, size: 48),
|
SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
Text(
|
||||||
'SWF / Flash',
|
'SWF / Flash is not supported in this build',
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.titleLarge
|
|
||||||
?.copyWith(color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.white70),
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (onRetry != null)
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: onRetry,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: () => launchUrl(
|
|
||||||
Uri.parse(url),
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.open_in_new_rounded),
|
|
||||||
label: const Text('Open original'),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor:
|
|
||||||
scheme.primaryContainer.withValues(alpha: 0.82),
|
|
||||||
),
|
|
||||||
onPressed: () => launchUrl(
|
|
||||||
Uri.parse(
|
|
||||||
'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}',
|
|
||||||
),
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.play_circle_outline_rounded),
|
|
||||||
label: const Text('Open in Ruffle'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1030,6 +808,26 @@ class _VideoSurface extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (errorMessage != null)
|
if (errorMessage != null)
|
||||||
VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
|
VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: !controlsVisible || errorMessage != null,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: controlsVisible || errorMessage != null ? 1 : 0,
|
||||||
|
duration: AppMotion.duration(context, 180),
|
||||||
|
child: _VideoControls(
|
||||||
|
player: player,
|
||||||
|
muted: muted,
|
||||||
|
halfVolume: halfVolume,
|
||||||
|
loopVideo: loopVideo,
|
||||||
|
coverVideo: coverVideo,
|
||||||
|
fullscreen: fullscreen,
|
||||||
|
onToggleFit: onToggleFit,
|
||||||
|
onToggleMute: onToggleMute,
|
||||||
|
onToggleHalfVolume: onToggleHalfVolume,
|
||||||
|
onToggleLoop: onToggleLoop,
|
||||||
|
onFullscreen: onFullscreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (errorMessage == null)
|
if (errorMessage == null)
|
||||||
StreamBuilder<bool>(
|
StreamBuilder<bool>(
|
||||||
stream: player.stream.playing,
|
stream: player.stream.playing,
|
||||||
@@ -1075,26 +873,6 @@ class _VideoSurface extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
|
||||||
ignoring: !controlsVisible || errorMessage != null,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: controlsVisible || errorMessage != null ? 1 : 0,
|
|
||||||
duration: AppMotion.duration(context, 180),
|
|
||||||
child: _VideoControls(
|
|
||||||
player: player,
|
|
||||||
muted: muted,
|
|
||||||
halfVolume: halfVolume,
|
|
||||||
loopVideo: loopVideo,
|
|
||||||
coverVideo: coverVideo,
|
|
||||||
fullscreen: fullscreen,
|
|
||||||
onToggleFit: onToggleFit,
|
|
||||||
onToggleMute: onToggleMute,
|
|
||||||
onToggleHalfVolume: onToggleHalfVolume,
|
|
||||||
onToggleLoop: onToggleLoop,
|
|
||||||
onFullscreen: onFullscreen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1170,10 +948,108 @@ class VideoErrorOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DioImageFallback extends StatefulWidget {
|
||||||
|
const _DioImageFallback({
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.headers,
|
||||||
|
required this.fit,
|
||||||
|
required this.onFailed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String imageUrl;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final BoxFit fit;
|
||||||
|
final VoidCallback onFailed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DioImageFallback> createState() => _DioImageFallbackState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DioImageFallbackState extends State<_DioImageFallback> {
|
||||||
|
late final Future<Uint8List> _bytes = _load();
|
||||||
|
bool _reportedFailure = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<Uint8List>(
|
||||||
|
future: _bytes,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final bytes = snapshot.data;
|
||||||
|
if (snapshot.hasError || bytes == null || bytes.isEmpty) {
|
||||||
|
_reportFailure();
|
||||||
|
return const _ImageLoadError();
|
||||||
|
}
|
||||||
|
return Image.memory(
|
||||||
|
bytes,
|
||||||
|
fit: widget.fit,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
_reportFailure();
|
||||||
|
return const _ImageLoadError();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _load() async {
|
||||||
|
final response = await Dio().get<List<int>>(
|
||||||
|
widget.imageUrl,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
headers: widget.headers,
|
||||||
|
followRedirects: true,
|
||||||
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
|
sendTimeout: const Duration(seconds: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final data = response.data;
|
||||||
|
if (response.statusCode == null ||
|
||||||
|
response.statusCode! < 200 ||
|
||||||
|
response.statusCode! >= 300 ||
|
||||||
|
data == null ||
|
||||||
|
data.isEmpty) {
|
||||||
|
throw StateError('Could not load image');
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportFailure() {
|
||||||
|
if (_reportedFailure) return;
|
||||||
|
_reportedFailure = true;
|
||||||
|
widget.onFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageLoadError extends StatelessWidget {
|
||||||
|
const _ImageLoadError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.broken_image_rounded, size: 48),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Could not load image'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ZoomableImage extends StatefulWidget {
|
class _ZoomableImage extends StatefulWidget {
|
||||||
const _ZoomableImage({required this.child});
|
const _ZoomableImage({
|
||||||
|
required this.child,
|
||||||
|
this.onGestureLockChanged,
|
||||||
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final ValueChanged<bool>? onGestureLockChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ZoomableImage> createState() => _ZoomableImageState();
|
State<_ZoomableImage> createState() => _ZoomableImageState();
|
||||||
@@ -1183,31 +1059,67 @@ class _ZoomableImageState extends State<_ZoomableImage> {
|
|||||||
final _controller = TransformationController();
|
final _controller = TransformationController();
|
||||||
TapDownDetails? _doubleTapDetails;
|
TapDownDetails? _doubleTapDetails;
|
||||||
bool _zoomed = false;
|
bool _zoomed = false;
|
||||||
|
int _pointerCount = 0;
|
||||||
|
bool _locked = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_setLocked(false);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return LayoutBuilder(
|
||||||
onDoubleTapDown: (details) => _doubleTapDetails = details,
|
builder: (context, constraints) {
|
||||||
onDoubleTap: _toggleZoom,
|
final hasFrame =
|
||||||
child: InteractiveViewer(
|
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
|
||||||
transformationController: _controller,
|
final child = hasFrame
|
||||||
minScale: 1,
|
? SizedBox(
|
||||||
maxScale: 5,
|
width: constraints.maxWidth,
|
||||||
panEnabled: _zoomed,
|
height: constraints.maxHeight,
|
||||||
scaleEnabled: true,
|
child: widget.child,
|
||||||
clipBehavior: Clip.none,
|
)
|
||||||
onInteractionEnd: (_) {
|
: widget.child;
|
||||||
final scale = _controller.value.getMaxScaleOnAxis();
|
return Listener(
|
||||||
if (mounted) setState(() => _zoomed = scale > 1.03);
|
onPointerDown: (_) {
|
||||||
},
|
_pointerCount++;
|
||||||
child: widget.child,
|
if (_pointerCount >= 2 || _zoomed) {
|
||||||
),
|
_setLocked(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerUp: (_) => _releasePointer(),
|
||||||
|
onPointerCancel: (_) => _releasePointer(),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onDoubleTapDown: (details) => _doubleTapDetails = details,
|
||||||
|
onDoubleTap: _toggleZoom,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _controller,
|
||||||
|
minScale: 1,
|
||||||
|
maxScale: 6,
|
||||||
|
boundaryMargin: const EdgeInsets.all(160),
|
||||||
|
panEnabled: _zoomed,
|
||||||
|
scaleEnabled: true,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
onInteractionStart: (_) {
|
||||||
|
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
|
||||||
|
},
|
||||||
|
onInteractionEnd: (_) {
|
||||||
|
final scale = _controller.value.getMaxScaleOnAxis();
|
||||||
|
final nextZoomed = scale > 1.03;
|
||||||
|
if (!nextZoomed) {
|
||||||
|
_controller.value = Matrix4.identity();
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _zoomed = nextZoomed);
|
||||||
|
_setLocked(nextZoomed || _pointerCount >= 2);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,12 +1128,26 @@ class _ZoomableImageState extends State<_ZoomableImage> {
|
|||||||
if (_zoomed) {
|
if (_zoomed) {
|
||||||
_controller.value = Matrix4.identity();
|
_controller.value = Matrix4.identity();
|
||||||
setState(() => _zoomed = false);
|
setState(() => _zoomed = false);
|
||||||
|
_setLocked(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const scale = 2.5;
|
||||||
_controller.value = Matrix4.identity()
|
_controller.value = Matrix4.identity()
|
||||||
..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1)
|
..translateByDouble(-tap.dx * (scale - 1), -tap.dy * (scale - 1), 0, 1)
|
||||||
..scaleByDouble(2.3, 2.3, 1, 1);
|
..scaleByDouble(scale, scale, 1, 1);
|
||||||
setState(() => _zoomed = true);
|
setState(() => _zoomed = true);
|
||||||
|
_setLocked(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releasePointer() {
|
||||||
|
if (_pointerCount > 0) _pointerCount--;
|
||||||
|
if (_pointerCount == 0 && !_zoomed) _setLocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLocked(bool value) {
|
||||||
|
if (_locked == value) return;
|
||||||
|
_locked = value;
|
||||||
|
widget.onGestureLockChanged?.call(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user