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

This commit is contained in:
2026-09-03 16:40:37 +03:00
parent 7cccb2cd8d
commit 45faa12133
55 changed files with 4263 additions and 2076 deletions
@@ -3,17 +3,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
// Меняем FutureProvider на StreamProvider
// Stream the fastest available post version first, then enrich it.
final postDetailsControllerProvider =
StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* {
// Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
final providerManager = ref.watch(providerManagerProvider);
final cacheService = ref.watch(cacheServiceProvider);
final postRepository = ref.watch(postRepositoryProvider);
// Вспомогательная функция для загрузки тегов
bool needsRealbooruDetails(Post post) {
if (post.providerId != 'realbooru') return false;
final preview = post.previewUrl.toLowerCase();
final sample = post.sampleUrl.toLowerCase();
return post.fileUrl.isEmpty ||
post.fileUrl == post.previewUrl ||
post.fileUrl == post.sampleUrl ||
preview.contains('/thumbnails/') ||
sample.contains('/thumbnails/');
}
Future<Post?> enrich(Post? post) async {
if (post == null) return null;
if (needsRealbooruDetails(post)) {
final remote = await providerManager.getPost(post.providerId, post.id);
if (remote is Success<Post?> && remote.data != null) {
await cacheService.cachePosts([remote.data!]);
return remote.data!;
}
}
final shouldEnrich = post.tagGroups.isEmpty ||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
if (!shouldEnrich) return post;
@@ -26,26 +42,29 @@ final postDetailsControllerProvider =
return post;
}
// СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай)
// Scenario 1: feed navigation already passed the post.
if (args.initialPost != null) {
yield args
.initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится!
if (!needsRealbooruDetails(args.initialPost!)) {
yield args.initialPost;
}
final enriched = await enrich(args.initialPost);
yield enriched; // Чуть позже отдаем версию с загруженными тегами
yield enriched;
return;
}
// СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш
// Scenario 2: direct navigation can use the local cache first.
final cached =
await postRepository.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) {
yield cached.data; // Нашли в кэше — сразу показываем контент
if (!needsRealbooruDetails(cached.data!)) {
yield cached.data;
}
final enriched = await enrich(cached.data);
yield enriched; // Догружаем теги на фоне
yield enriched;
return;
}
// СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом
// Scenario 3: fall back to the provider when nothing local is available.
final remote = await providerManager.getPost(args.providerId, args.postId);
if (remote is Success<Post?> && remote.data != null) {
yield remote.data;
@@ -94,7 +94,9 @@ class PostDetailsScreen extends ConsumerWidget {
return _MobilePostPager(
posts: feedPosts,
initialIndex: currentIndex,
buildDetails: (context, post) => _buildMobileDetails(
buildDetails: (context, post, mediaGestureLocked,
onMediaGestureLockChanged) =>
_buildMobileDetails(
context,
ref,
post,
@@ -102,6 +104,8 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
qualityMode,
strings,
mediaGestureLocked,
onMediaGestureLockChanged,
),
);
}
@@ -113,6 +117,8 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
qualityMode,
strings,
false,
null,
);
}
return Shortcuts(
@@ -186,17 +192,29 @@ class PostDetailsScreen extends ConsumerWidget {
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: SizedBox(
width: double.infinity,
height: 760,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
mediaHeaders: ref
.watch(postMediaHeadersProvider(post))
.value ??
const {},
initialPosition: Duration(
milliseconds: settings.videoPlaybackPositions[
post.cacheKey] ??
0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume:
settings.videoPlayerHalfVolume,
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
),
@@ -233,6 +251,7 @@ class PostDetailsScreen extends ConsumerWidget {
_toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
@@ -284,11 +303,14 @@ class PostDetailsScreen extends ConsumerWidget {
Set<String> favoriteKeys,
MediaQualityMode qualityMode,
AppStrings strings,
bool mediaGestureLocked,
ValueChanged<bool>? onMediaGestureLockChanged,
) {
final isVideo = MediaUrlSelector.isVideo(post);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
if (mediaGestureLocked) return;
if ((details.primaryVelocity ?? 0) > 900) _close(context);
},
child: ListView(
@@ -302,22 +324,30 @@ class PostDetailsScreen extends ConsumerWidget {
? null
: () =>
_showMobileQuickActions(context, ref, post, favoriteKeys),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
),
child: Center(
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume,
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
child: SizedBox(
width: double.infinity,
height: MediaQuery.sizeOf(context).height * 0.62,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
mediaHeaders:
ref.watch(postMediaHeadersProvider(post)).value ?? const {},
initialPosition: Duration(
milliseconds:
settings.videoPlaybackPositions[post.cacheKey] ?? 0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume,
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
onMediaGestureLockChanged: (locked) {
onMediaGestureLockChanged?.call(locked);
},
),
),
),
@@ -331,6 +361,7 @@ class PostDetailsScreen extends ConsumerWidget {
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onHide: () => _hidePost(context, ref, post),
@@ -545,6 +576,18 @@ class PostDetailsScreen extends ConsumerWidget {
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
}
Future<void> _openSourcePage(WidgetRef ref, Post post) async {
final source = post.source?.trim();
String? url = source != null && source.isNotEmpty ? source : null;
if (url == null) {
final result =
await ref.read(providerManagerProvider).getPostPageUrl(post);
if (result is Success<String?>) url = result.data;
}
if (url == null || url.isEmpty) return;
await launchUrl(Uri.parse(url));
}
void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post);
}
@@ -580,6 +623,18 @@ class PostDetailsScreen extends ConsumerWidget {
);
ref.invalidate(appSettingsProvider);
}
Future<void> _saveVideoSnapshot(
WidgetRef ref,
Post post,
VideoPlaybackSnapshot snapshot,
) async {
await ref.read(settingsServiceProvider).saveVideoPlaybackPosition(
post.cacheKey,
snapshot.position.inMilliseconds,
);
ref.invalidate(appSettingsProvider);
}
}
class _MobilePostPager extends StatefulWidget {
@@ -591,19 +646,25 @@ class _MobilePostPager extends StatefulWidget {
final List<Post> posts;
final int initialIndex;
final Widget Function(BuildContext context, Post post) buildDetails;
final Widget Function(
BuildContext context,
Post post,
bool mediaGestureLocked,
ValueChanged<bool> onMediaGestureLockChanged,
) buildDetails;
@override
State<_MobilePostPager> createState() => _MobilePostPagerState();
}
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: widget.initialIndex);
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
bool _mediaGestureLocked = false;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
});
@@ -613,11 +674,11 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -634,22 +695,50 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
Widget build(BuildContext context) {
return PageView.builder(
controller: _controller,
itemCount: widget.posts.length,
onPageChanged: (index) {
_prefetchAround(index);
},
controller: _controller,
physics: _mediaGestureLocked
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
itemCount: widget.posts.length,
onPageChanged: (index) {
_prefetchAround(index);
},
itemBuilder: (context, index) {
return _KeepAlivePostPage(
child: KeyedSubtree(
key: ValueKey(widget.posts[index].cacheKey),
child: widget.buildDetails(context, widget.posts[index]),
),
final initialPost = widget.posts[index];
return Consumer(
builder: (context, ref, _) {
final args = PostDetailsArgs(
providerId: initialPost.providerId,
postId: initialPost.id,
initialPost: initialPost,
);
final post = ref.watch(postDetailsControllerProvider(args));
return post.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (resolvedPost) => _KeepAlivePostPage(
child: KeyedSubtree(
key: ValueKey((resolvedPost ?? initialPost).cacheKey),
child: widget.buildDetails(
context,
resolvedPost ?? initialPost,
_mediaGestureLocked,
_setMediaGestureLocked,
),
),
),
);
},
);
},
);
}
void _setMediaGestureLocked(bool locked) {
if (_mediaGestureLocked == locked) return;
setState(() => _mediaGestureLocked = locked);
}
void _prefetchAround(int index) {
for (final offset in [-2, -1, 0, 1, 2]) {
final target = index + offset;
@@ -670,19 +759,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
}
Map<String, String> _headersFor(Post post) {
return {
return const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') ||
post.sampleUrl.contains('gelbooru.com') ||
post.previewUrl.contains('gelbooru.com'))
'Referer': 'https://gelbooru.com/',
if (post.providerName.toLowerCase().contains('rule34') ||
post.fileUrl.contains('rule34') ||
post.sampleUrl.contains('rule34') ||
post.previewUrl.contains('rule34'))
'Referer': 'https://rule34.xxx/',
};
}
}
@@ -7,6 +7,7 @@ class PostActionBar extends StatelessWidget {
required this.onOpen,
required this.onCopy,
required this.isFavorite,
this.onOpenSource,
this.onDownload,
this.onDeleteLocalFile,
this.downloaded = false,
@@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget {
final VoidCallback onCollection;
final VoidCallback onOpen;
final VoidCallback onCopy;
final VoidCallback? onOpenSource;
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile;
@@ -58,6 +60,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onOpen,
icon: const Icon(Icons.open_in_new_rounded),
),
if (onOpenSource != null)
IconButton.filledTonal(
tooltip: text.openSource,
onPressed: onOpenSource,
icon: const Icon(Icons.article_rounded),
),
IconButton.filledTonal(
tooltip: text.copyLink,
onPressed: onCopy,
@@ -95,6 +103,7 @@ class PostActionLabels {
this.collection = 'Collection',
this.similar = 'Similar',
this.openOriginal = 'Open original',
this.openSource = 'Open source/page',
this.copyLink = 'Copy link',
this.download = 'Download',
this.deleteLocalFile = 'Delete local file',
@@ -106,6 +115,7 @@ class PostActionLabels {
final String collection;
final String similar;
final String openOriginal;
final String openSource;
final String copyLink;
final String download;
final String deleteLocalFile;
File diff suppressed because it is too large Load Diff