feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -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
Reference in New Issue
Block a user