Add similar posts, provider hints and app icon
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 5.8 MiB |
@@ -1,2 +1,2 @@
|
|||||||
const appDisplayVersion = '0.4.0';
|
const appDisplayVersion = '0.4.2';
|
||||||
const appBuildNumber = 4;
|
const appBuildNumber = 6;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../features/collections/presentation/collections_screen.dart';
|
|||||||
import '../features/favorites/presentation/favorites_screen.dart';
|
import '../features/favorites/presentation/favorites_screen.dart';
|
||||||
import '../features/feed/presentation/feed_screen.dart';
|
import '../features/feed/presentation/feed_screen.dart';
|
||||||
import '../features/post/presentation/post_details_screen.dart';
|
import '../features/post/presentation/post_details_screen.dart';
|
||||||
|
import '../features/post/presentation/similar_posts_screen.dart';
|
||||||
import '../features/providers/presentation/provider_check_screen.dart';
|
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';
|
||||||
@@ -40,6 +41,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/post/:providerId/:postId/similar',
|
||||||
|
builder: (context, state) => SimilarPostsScreen(
|
||||||
|
providerId: state.pathParameters['providerId']!,
|
||||||
|
postId: state.pathParameters['postId']!,
|
||||||
|
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/favorites',
|
path: '/favorites',
|
||||||
builder: (context, state) => const FavoritesScreen(),
|
builder: (context, state) => const FavoritesScreen(),
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ class ProviderManager {
|
|||||||
providers.where((provider) => provider.id == providerId).toList();
|
providers.where((provider) => provider.id == providerId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final posts = <Post>[];
|
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
|
final providerResults = <List<Post>>[];
|
||||||
for (final provider in providers) {
|
for (final provider in providers) {
|
||||||
try {
|
try {
|
||||||
final providerPosts = await provider.searchPosts(
|
final providerPosts = await provider.searchPosts(
|
||||||
@@ -108,9 +108,7 @@ class ProviderManager {
|
|||||||
rating: rating,
|
rating: rating,
|
||||||
topPeriod: topPeriod,
|
topPeriod: topPeriod,
|
||||||
);
|
);
|
||||||
for (final post in providerPosts) {
|
providerResults.add(providerPosts);
|
||||||
if (seen.add(post.cacheKey)) posts.add(post);
|
|
||||||
}
|
|
||||||
await _repository.saveDiagnostics(
|
await _repository.saveDiagnostics(
|
||||||
ProviderDiagnostics(
|
ProviderDiagnostics(
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
@@ -138,9 +136,45 @@ class ProviderManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final posts = providerId == null
|
||||||
|
? _interleaveProviderResults(providerResults, seen)
|
||||||
|
: _flattenProviderResults(providerResults, seen);
|
||||||
return Success(posts);
|
return Success(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Post> _flattenProviderResults(
|
||||||
|
List<List<Post>> results, Set<String> seen) {
|
||||||
|
final posts = <Post>[];
|
||||||
|
for (final providerPosts in results) {
|
||||||
|
for (final post in providerPosts) {
|
||||||
|
if (seen.add(post.cacheKey)) posts.add(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Post> _interleaveProviderResults(
|
||||||
|
List<List<Post>> results,
|
||||||
|
Set<String> seen,
|
||||||
|
) {
|
||||||
|
final posts = <Post>[];
|
||||||
|
var index = 0;
|
||||||
|
while (true) {
|
||||||
|
var added = false;
|
||||||
|
for (final providerPosts in results) {
|
||||||
|
if (index >= providerPosts.length) continue;
|
||||||
|
final post = providerPosts[index];
|
||||||
|
if (seen.add(post.cacheKey)) {
|
||||||
|
posts.add(post);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added && results.every((items) => index >= items.length)) break;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<List<PostComment>>> getComments(
|
Future<Result<List<PostComment>>> getComments(
|
||||||
String providerId,
|
String providerId,
|
||||||
String postId,
|
String postId,
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
|||||||
? <String?>[null]
|
? <String?>[null]
|
||||||
: current.selectedProviderIds;
|
: current.selectedProviderIds;
|
||||||
final posts = <Post>[];
|
final posts = <Post>[];
|
||||||
final seen = <String>{};
|
final providerResults = <List<Post>>[];
|
||||||
for (final providerId in providerIds) {
|
for (final providerId in providerIds) {
|
||||||
final result = refresh
|
final result = refresh
|
||||||
? await service.refresh(
|
? await service.refresh(
|
||||||
@@ -193,10 +193,33 @@ class FeedController extends AsyncNotifier<FeedState> {
|
|||||||
topPeriod: current.topPeriodFilter,
|
topPeriod: current.topPeriodFilter,
|
||||||
);
|
);
|
||||||
if (result is Success<List<Post>>) {
|
if (result is Success<List<Post>>) {
|
||||||
for (final post in result.data) {
|
providerResults.add(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final seen = <String>{};
|
||||||
|
if (providerResults.length <= 1) {
|
||||||
|
for (final group in providerResults) {
|
||||||
|
for (final post in group) {
|
||||||
if (seen.add(post.cacheKey)) posts.add(post);
|
if (seen.add(post.cacheKey)) posts.add(post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
var index = 0;
|
||||||
|
while (true) {
|
||||||
|
var added = false;
|
||||||
|
for (final group in providerResults) {
|
||||||
|
if (index >= group.length) continue;
|
||||||
|
final post = group[index];
|
||||||
|
if (seen.add(post.cacheKey)) {
|
||||||
|
posts.add(post);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added && providerResults.every((group) => index >= group.length)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
return posts;
|
return posts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 760),
|
constraints: const BoxConstraints(maxHeight: 760),
|
||||||
child: PostMediaViewer(
|
child: PostMediaViewer(
|
||||||
|
key: ValueKey(post.cacheKey),
|
||||||
post: post,
|
post: post,
|
||||||
qualityMode: qualityMode,
|
qualityMode: qualityMode,
|
||||||
),
|
),
|
||||||
@@ -261,61 +262,78 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
Set<String> favoriteKeys,
|
Set<String> favoriteKeys,
|
||||||
MediaQualityMode qualityMode,
|
MediaQualityMode qualityMode,
|
||||||
) {
|
) {
|
||||||
return ListView(
|
final isVideo = MediaUrlSelector.isVideo(post);
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
return GestureDetector(
|
||||||
children: [
|
behavior: HitTestBehavior.translucent,
|
||||||
ConstrainedBox(
|
onVerticalDragEnd: (details) {
|
||||||
constraints: BoxConstraints(
|
if ((details.primaryVelocity ?? 0) > 900) _close(context);
|
||||||
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
|
},
|
||||||
),
|
child: ListView(
|
||||||
child: Center(
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||||
child: PostMediaViewer(
|
children: [
|
||||||
post: post,
|
GestureDetector(
|
||||||
qualityMode: qualityMode,
|
behavior: HitTestBehavior.deferToChild,
|
||||||
),
|
onDoubleTap:
|
||||||
),
|
isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
|
||||||
),
|
onLongPress: isVideo
|
||||||
const SizedBox(height: 12),
|
? null
|
||||||
PostActionBar(
|
: () =>
|
||||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
_showMobileQuickActions(context, ref, post, favoriteKeys),
|
||||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
child: ConstrainedBox(
|
||||||
onCollection: () => _addToCollection(context, ref, post),
|
constraints: BoxConstraints(
|
||||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
|
||||||
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
),
|
||||||
onSimilar: () => _openSimilar(context, ref, post),
|
child: Center(
|
||||||
onDownload: settings.allowDownloads
|
child: PostMediaViewer(
|
||||||
? () => _download(context, ref, post)
|
key: ValueKey(post.cacheKey),
|
||||||
: null,
|
post: post,
|
||||||
),
|
qualityMode: qualityMode,
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
Chip(label: Text(post.providerName)),
|
|
||||||
RatingBadge(rating: post.rating),
|
|
||||||
Chip(label: Text('${post.width} x ${post.height}')),
|
|
||||||
if (post.source != null && post.source!.isNotEmpty)
|
|
||||||
ActionChip(
|
|
||||||
label: const Text('Source'),
|
|
||||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ExpansionTile(
|
|
||||||
tilePadding: EdgeInsets.zero,
|
|
||||||
initiallyExpanded: false,
|
|
||||||
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: PostTagsPanel(post: post),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
_CommentsSection(post: post),
|
PostActionBar(
|
||||||
],
|
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||||
|
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||||
|
onCollection: () => _addToCollection(context, ref, post),
|
||||||
|
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||||
|
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||||
|
onSimilar: () => _openSimilar(context, ref, post),
|
||||||
|
onDownload: settings.allowDownloads
|
||||||
|
? () => _download(context, ref, post)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text(post.providerName)),
|
||||||
|
RatingBadge(rating: post.rating),
|
||||||
|
Chip(label: Text('${post.width} x ${post.height}')),
|
||||||
|
if (post.source != null && post.source!.isNotEmpty)
|
||||||
|
ActionChip(
|
||||||
|
label: const Text('Source'),
|
||||||
|
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
initiallyExpanded: false,
|
||||||
|
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: PostTagsPanel(post: post),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_CommentsSection(post: post),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +367,65 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showMobileQuickActions(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Post post,
|
||||||
|
Set<String> favoriteKeys,
|
||||||
|
) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
favoriteKeys.contains(post.cacheKey)
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
favoriteKeys.contains(post.cacheKey)
|
||||||
|
? 'Remove favorite'
|
||||||
|
: 'Favorite',
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _toggleFavorite(ref, post, favoriteKeys);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.collections_bookmark_rounded),
|
||||||
|
title: const Text('Add to collection'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_addToCollection(context, ref, post);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.copy_rounded),
|
||||||
|
title: const Text('Copy link'),
|
||||||
|
onTap: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: post.fileUrl));
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.download_rounded),
|
||||||
|
title: const Text('Download'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_download(context, ref, post);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavorite(
|
Future<void> _toggleFavorite(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Post post,
|
Post post,
|
||||||
@@ -370,10 +447,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
|
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
|
||||||
final query = similarTagsFor(post).join(' ');
|
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
|
||||||
if (query.trim().isEmpty) return;
|
|
||||||
ref.read(feedControllerProvider.notifier).search(query);
|
|
||||||
context.go('/?q=${Uri.encodeQueryComponent(query)}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _replacePost(BuildContext context, Post post) {
|
void _replacePost(BuildContext context, Post post) {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/responsive.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
|
import '../../../core/utils/result.dart';
|
||||||
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
|
import '../../../shared/widgets/empty_view.dart';
|
||||||
|
import '../../../shared/widgets/error_view.dart';
|
||||||
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
|
import '../../viewed/presentation/viewed_controller.dart';
|
||||||
|
import 'post_details_controller.dart';
|
||||||
|
|
||||||
|
final similarPostsProvider =
|
||||||
|
FutureProvider.family<List<Post>, PostDetailsArgs>((ref, args) async {
|
||||||
|
final source = args.initialPost ??
|
||||||
|
await ref.watch(postDetailsControllerProvider(args).future);
|
||||||
|
if (source == null) return const [];
|
||||||
|
final tags = similarTagsFor(source);
|
||||||
|
if (tags.isEmpty) return const [];
|
||||||
|
final result = await ref.watch(feedServiceProvider).refresh(
|
||||||
|
tags: tags,
|
||||||
|
limit: 60,
|
||||||
|
);
|
||||||
|
if (result is! Success<List<Post>>) return const [];
|
||||||
|
return result.data
|
||||||
|
.where((post) => post.cacheKey != source.cacheKey)
|
||||||
|
.toList(growable: false);
|
||||||
|
});
|
||||||
|
|
||||||
|
class SimilarPostsScreen extends ConsumerWidget {
|
||||||
|
const SimilarPostsScreen({
|
||||||
|
required this.providerId,
|
||||||
|
required this.postId,
|
||||||
|
this.initialPost,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String providerId;
|
||||||
|
final String postId;
|
||||||
|
final Post? initialPost;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final args = PostDetailsArgs(
|
||||||
|
providerId: providerId,
|
||||||
|
postId: postId,
|
||||||
|
initialPost: initialPost,
|
||||||
|
);
|
||||||
|
final source = ref.watch(postDetailsControllerProvider(args));
|
||||||
|
final similar = ref.watch(similarPostsProvider(args));
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final favorites = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
|
final viewed = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||||
|
final columns = Responsive.isMobile(context)
|
||||||
|
? settings.mobileColumns
|
||||||
|
: settings.desktopColumns;
|
||||||
|
|
||||||
|
return AdaptiveScaffold(
|
||||||
|
title: 'Similar',
|
||||||
|
body: source.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
|
data: (post) {
|
||||||
|
final tags = post == null ? const <String>[] : similarTagsFor(post);
|
||||||
|
return similar.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
|
data: (posts) {
|
||||||
|
if (tags.isEmpty || posts.isEmpty) {
|
||||||
|
return const EmptyView(title: 'No similar posts yet');
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final tag in tags.take(8)) Chip(label: Text(tag)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: PostMasonryGrid(
|
||||||
|
posts: posts,
|
||||||
|
columns: columns,
|
||||||
|
blurExplicit: settings.blurExplicitContent,
|
||||||
|
showBadges: settings.showPostBadges,
|
||||||
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
favoriteKeys: favorites,
|
||||||
|
viewedKeys: viewed,
|
||||||
|
onOpen: (post) => context.push(
|
||||||
|
'/post/${post.providerId}/${post.id}',
|
||||||
|
extra: post,
|
||||||
|
),
|
||||||
|
onFavorite: (post) =>
|
||||||
|
_toggleFavorite(ref, post, favorites),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite(
|
||||||
|
WidgetRef ref,
|
||||||
|
Post post,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
ref.invalidate(favoriteKeysProvider);
|
||||||
|
ref.invalidate(viewedControllerProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,8 @@ class PostMediaViewer extends StatefulWidget {
|
|||||||
State<PostMediaViewer> createState() => _PostMediaViewerState();
|
State<PostMediaViewer> createState() => _PostMediaViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostMediaViewerState extends State<PostMediaViewer> {
|
class _PostMediaViewerState extends State<PostMediaViewer>
|
||||||
|
with AutomaticKeepAliveClientMixin {
|
||||||
Player? _player;
|
Player? _player;
|
||||||
VideoController? _controller;
|
VideoController? _controller;
|
||||||
late List<String> _imageUrls;
|
late List<String> _imageUrls;
|
||||||
@@ -52,6 +53,7 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
int _imageIndex = 0;
|
int _imageIndex = 0;
|
||||||
int _videoIndex = 0;
|
int _videoIndex = 0;
|
||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
|
bool _inFullscreen = false;
|
||||||
late bool _coverVideo;
|
late bool _coverVideo;
|
||||||
late bool _muted;
|
late bool _muted;
|
||||||
late bool _loopVideo;
|
late bool _loopVideo;
|
||||||
@@ -98,8 +100,12 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
if (_isSwf(widget.post)) {
|
if (_isSwf(widget.post)) {
|
||||||
return _SwfMediaViewer(
|
return _SwfMediaViewer(
|
||||||
post: widget.post,
|
post: widget.post,
|
||||||
@@ -109,6 +115,19 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_controller != null) {
|
if (_controller != null) {
|
||||||
|
if (_inFullscreen) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: widget.post.width > 0 && widget.post.height > 0
|
||||||
|
? (widget.post.width / widget.post.height).clamp(0.35, 2.4)
|
||||||
|
: 16 / 9,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.fullscreen_rounded, color: Colors.white38),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Shortcuts(
|
return Shortcuts(
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
|
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
|
||||||
@@ -347,22 +366,26 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
|
|
||||||
Future<void> _openFullscreen(BuildContext context) async {
|
Future<void> _openFullscreen(BuildContext context) async {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
final position = player?.state.position ?? Duration.zero;
|
final controller = _controller;
|
||||||
final wasPlaying = player?.state.playing ?? false;
|
if (player == null || controller == null) return;
|
||||||
if (wasPlaying) await player?.pause();
|
setState(() => _inFullscreen = true);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final result = await Navigator.of(context, rootNavigator: true)
|
final result = await Navigator.of(context, rootNavigator: true)
|
||||||
.push<VideoPlaybackSnapshot>(
|
.push<VideoPlaybackSnapshot>(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
opaque: true,
|
opaque: true,
|
||||||
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
|
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
|
||||||
post: widget.post,
|
player: player,
|
||||||
startAt: position,
|
controller: controller,
|
||||||
autoplay: wasPlaying,
|
aspectRatio: widget.post.width > 0 && widget.post.height > 0
|
||||||
|
? widget.post.width / widget.post.height
|
||||||
|
: 16 / 9,
|
||||||
loopVideo: _loopVideo,
|
loopVideo: _loopVideo,
|
||||||
muted: _muted,
|
muted: _muted,
|
||||||
halfVolume: _halfVolume,
|
halfVolume: _halfVolume,
|
||||||
coverVideo: _coverVideo,
|
coverVideo: _coverVideo,
|
||||||
|
errorMessage: _videoError,
|
||||||
|
onRetry: _retryVideo,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
@@ -370,7 +393,8 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result != null && player != null) {
|
if (mounted) setState(() => _inFullscreen = false);
|
||||||
|
if (result != null) {
|
||||||
_playbackMemory[widget.post.cacheKey] = result;
|
_playbackMemory[widget.post.cacheKey] = result;
|
||||||
setState(() {
|
setState(() {
|
||||||
_muted = result.muted;
|
_muted = result.muted;
|
||||||
@@ -382,11 +406,8 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
|||||||
await player.setPlaylistMode(
|
await player.setPlaylistMode(
|
||||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||||
);
|
);
|
||||||
await player.seek(result.position);
|
|
||||||
if (result.playing) await player.play();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (wasPlaying) await player?.play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoPlaybackSnapshot _snapshot() {
|
VideoPlaybackSnapshot _snapshot() {
|
||||||
@@ -712,51 +733,58 @@ class VideoPlaybackSnapshot {
|
|||||||
|
|
||||||
class _FullscreenVideoPage extends StatefulWidget {
|
class _FullscreenVideoPage extends StatefulWidget {
|
||||||
const _FullscreenVideoPage({
|
const _FullscreenVideoPage({
|
||||||
required this.post,
|
required this.player,
|
||||||
required this.startAt,
|
required this.controller,
|
||||||
required this.autoplay,
|
required this.aspectRatio,
|
||||||
required this.loopVideo,
|
required this.loopVideo,
|
||||||
required this.muted,
|
required this.muted,
|
||||||
required this.halfVolume,
|
required this.halfVolume,
|
||||||
required this.coverVideo,
|
required this.coverVideo,
|
||||||
|
required this.errorMessage,
|
||||||
|
required this.onRetry,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Post post;
|
final Player player;
|
||||||
final Duration startAt;
|
final VideoController controller;
|
||||||
final bool autoplay;
|
final double aspectRatio;
|
||||||
final bool loopVideo;
|
final bool loopVideo;
|
||||||
final bool muted;
|
final bool muted;
|
||||||
final bool halfVolume;
|
final bool halfVolume;
|
||||||
final bool coverVideo;
|
final bool coverVideo;
|
||||||
|
final String? errorMessage;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
|
State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||||
late VideoPlaybackSnapshot _snapshot;
|
bool _controlsVisible = true;
|
||||||
|
late bool _coverVideo;
|
||||||
|
late bool _muted;
|
||||||
|
late bool _loopVideo;
|
||||||
|
late bool _halfVolume;
|
||||||
|
Timer? _hideTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_snapshot = VideoPlaybackSnapshot(
|
_coverVideo = widget.coverVideo;
|
||||||
position: widget.startAt,
|
_muted = widget.muted;
|
||||||
playing: widget.autoplay,
|
_loopVideo = widget.loopVideo;
|
||||||
muted: widget.muted,
|
_halfVolume = widget.halfVolume;
|
||||||
halfVolume: widget.halfVolume,
|
|
||||||
loopVideo: widget.loopVideo,
|
|
||||||
coverVideo: widget.coverVideo,
|
|
||||||
);
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.landscapeLeft,
|
DeviceOrientation.landscapeLeft,
|
||||||
DeviceOrientation.landscapeRight,
|
DeviceOrientation.landscapeRight,
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
]);
|
]);
|
||||||
|
_scheduleControlsHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_hideTimer?.cancel();
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -768,43 +796,139 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
|||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
Navigator.of(context, rootNavigator: true).pop(_snapshot);
|
Navigator.of(context, rootNavigator: true).pop(_snapshot());
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Stack(
|
body: Shortcuts(
|
||||||
children: [
|
shortcuts: {
|
||||||
Positioned.fill(
|
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
|
||||||
child: PostMediaViewer(
|
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
|
||||||
post: widget.post,
|
},
|
||||||
fullscreen: true,
|
child: Actions(
|
||||||
initialPosition: widget.startAt,
|
actions: {
|
||||||
autoplay: widget.autoplay,
|
_CloseVideoIntent: CallbackAction<_CloseVideoIntent>(
|
||||||
initialLoop: widget.loopVideo,
|
onInvoke: (_) {
|
||||||
initialMuted: widget.muted,
|
Navigator.of(context, rootNavigator: true).pop(_snapshot());
|
||||||
initialHalfVolume: widget.halfVolume,
|
return null;
|
||||||
initialCoverVideo: widget.coverVideo,
|
},
|
||||||
qualityMode: MediaQualityMode.highQuality,
|
),
|
||||||
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot,
|
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
widget.player.playOrPause();
|
||||||
|
_showControls();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: Focus(
|
||||||
|
autofocus: true,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: _VideoSurface(
|
||||||
|
player: widget.player,
|
||||||
|
controller: widget.controller,
|
||||||
|
aspectRatio: widget.aspectRatio,
|
||||||
|
controlsVisible: _controlsVisible,
|
||||||
|
coverVideo: _coverVideo,
|
||||||
|
muted: _muted,
|
||||||
|
loopVideo: _loopVideo,
|
||||||
|
halfVolume: _halfVolume,
|
||||||
|
fullscreen: true,
|
||||||
|
errorMessage: widget.errorMessage,
|
||||||
|
onInteract: _showControls,
|
||||||
|
onRetry: widget.onRetry,
|
||||||
|
onToggleFit: () {
|
||||||
|
setState(() => _coverVideo = !_coverVideo);
|
||||||
|
_showControls();
|
||||||
|
},
|
||||||
|
onToggleMute: () async {
|
||||||
|
setState(() => _muted = !_muted);
|
||||||
|
await _applyVolume();
|
||||||
|
_showControls();
|
||||||
|
},
|
||||||
|
onToggleHalfVolume: () async {
|
||||||
|
setState(() {
|
||||||
|
_halfVolume = !_halfVolume;
|
||||||
|
if (_halfVolume) _muted = false;
|
||||||
|
});
|
||||||
|
await _applyVolume();
|
||||||
|
_showControls();
|
||||||
|
},
|
||||||
|
onToggleLoop: () async {
|
||||||
|
setState(() => _loopVideo = !_loopVideo);
|
||||||
|
await widget.player.setPlaylistMode(
|
||||||
|
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||||
|
);
|
||||||
|
_showControls();
|
||||||
|
},
|
||||||
|
onFullscreen: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
rootNavigator: true,
|
||||||
|
).pop(_snapshot()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: SafeArea(
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
tooltip: 'Close fullscreen',
|
||||||
|
onPressed: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
rootNavigator: true,
|
||||||
|
).pop(_snapshot()),
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
),
|
||||||
top: 12,
|
|
||||||
right: 12,
|
|
||||||
child: SafeArea(
|
|
||||||
child: IconButton.filledTonal(
|
|
||||||
tooltip: 'Close fullscreen',
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.of(context, rootNavigator: true).pop(_snapshot),
|
|
||||||
icon: const Icon(Icons.close_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showControls() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _controlsVisible = true);
|
||||||
|
_scheduleControlsHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleControlsHide() {
|
||||||
|
_hideTimer?.cancel();
|
||||||
|
_hideTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted && widget.errorMessage == null) {
|
||||||
|
setState(() => _controlsVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyVolume() async {
|
||||||
|
await widget.player.setVolume(_muted
|
||||||
|
? 0
|
||||||
|
: _halfVolume
|
||||||
|
? 50
|
||||||
|
: 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlaybackSnapshot _snapshot() {
|
||||||
|
return VideoPlaybackSnapshot(
|
||||||
|
position: widget.player.state.position,
|
||||||
|
playing: widget.player.state.playing,
|
||||||
|
muted: _muted,
|
||||||
|
halfVolume: _halfVolume,
|
||||||
|
loopVideo: _loopVideo,
|
||||||
|
coverVideo: _coverVideo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CloseVideoIntent extends Intent {
|
||||||
|
const _CloseVideoIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoSurface extends StatelessWidget {
|
class _VideoSurface extends StatelessWidget {
|
||||||
@@ -1039,17 +1163,17 @@ class _RoundControlButton extends StatelessWidget {
|
|||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkResponse(
|
child: InkResponse(
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
radius: emphasized ? 30 : 24,
|
radius: emphasized ? 23 : 21,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: const Duration(milliseconds: 140),
|
||||||
width: emphasized ? 48 : 40,
|
width: emphasized ? 38 : 34,
|
||||||
height: emphasized ? 48 : 40,
|
height: emphasized ? 38 : 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background,
|
color: background,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: foreground),
|
child: Icon(icon, color: foreground, size: emphasized ? 24 : 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1086,26 +1210,26 @@ class _VideoControls 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 DecoratedBox(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.bottomCenter,
|
begin: Alignment.bottomCenter,
|
||||||
end: Alignment.topCenter,
|
end: Alignment.topCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.black.withValues(alpha: 0.78),
|
Colors.black.withValues(alpha: 0.72),
|
||||||
Colors.black.withValues(alpha: 0.22),
|
Colors.black.withValues(alpha: 0.12),
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Padding(
|
child: SafeArea(
|
||||||
padding: EdgeInsets.fromLTRB(
|
minimum: EdgeInsets.fromLTRB(
|
||||||
fullscreen ? 22 : 12,
|
fullscreen ? 14 : 8,
|
||||||
12,
|
0,
|
||||||
fullscreen ? 22 : 12,
|
fullscreen ? 14 : 8,
|
||||||
fullscreen ? 22 : 12,
|
fullscreen ? 10 : 8,
|
||||||
),
|
),
|
||||||
child: StreamBuilder<bool>(
|
child: StreamBuilder<bool>(
|
||||||
stream: player.stream.playing,
|
stream: player.stream.playing,
|
||||||
@@ -1129,162 +1253,135 @@ class _VideoControls extends StatelessWidget {
|
|||||||
.toDouble();
|
.toDouble();
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final compact = constraints.maxWidth < 980;
|
final compact = constraints.maxWidth < 560;
|
||||||
return DecoratedBox(
|
final tiny = constraints.maxWidth < 390;
|
||||||
decoration: BoxDecoration(
|
return Column(
|
||||||
color: Colors.black.withValues(alpha: 0.58),
|
mainAxisSize: MainAxisSize.min,
|
||||||
borderRadius: BorderRadius.circular(22),
|
children: [
|
||||||
border: Border.all(
|
SliderTheme(
|
||||||
color: Colors.white.withValues(alpha: 0.12),
|
data: SliderTheme.of(context).copyWith(
|
||||||
),
|
activeTrackColor: scheme.primary,
|
||||||
boxShadow: const [
|
inactiveTrackColor:
|
||||||
BoxShadow(
|
Colors.white.withValues(alpha: 0.24),
|
||||||
color: Colors.black54,
|
trackHeight: 3,
|
||||||
blurRadius: 26,
|
thumbColor: scheme.primary,
|
||||||
offset: Offset(0, 12),
|
overlayColor:
|
||||||
|
scheme.primary.withValues(alpha: 0.18),
|
||||||
|
thumbShape: const RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: valueMs,
|
||||||
|
max: maxMs,
|
||||||
|
onChanged: duration == Duration.zero
|
||||||
|
? null
|
||||||
|
: (value) => player.seek(
|
||||||
|
Duration(
|
||||||
|
milliseconds: value.round(),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
compact ? 10 : 14,
|
|
||||||
8,
|
|
||||||
compact ? 10 : 14,
|
|
||||||
compact ? 10 : 12,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
SliderTheme(
|
_RoundControlButton(
|
||||||
data: SliderTheme.of(context).copyWith(
|
tooltip: playing ? 'Pause' : 'Play',
|
||||||
activeTrackColor: scheme.primary,
|
emphasized: true,
|
||||||
inactiveTrackColor:
|
onPressed: player.playOrPause,
|
||||||
Colors.white.withValues(alpha: 0.22),
|
icon: playing
|
||||||
trackHeight: 5,
|
? Icons.pause_rounded
|
||||||
thumbColor: scheme.primary,
|
: Icons.play_arrow_rounded,
|
||||||
overlayColor:
|
),
|
||||||
scheme.primary.withValues(alpha: 0.2),
|
const SizedBox(width: 8),
|
||||||
thumbShape: const RoundSliderThumbShape(
|
Text(
|
||||||
enabledThumbRadius: 7,
|
tiny
|
||||||
),
|
? _format(position)
|
||||||
),
|
: '${_format(position)} / ${_format(duration)}',
|
||||||
child: Slider(
|
style: const TextStyle(
|
||||||
value: valueMs,
|
color: Colors.white,
|
||||||
max: maxMs,
|
fontSize: 12,
|
||||||
onChanged: duration == Duration.zero
|
fontWeight: FontWeight.w700,
|
||||||
? null
|
|
||||||
: (value) => player.seek(
|
|
||||||
Duration(
|
|
||||||
milliseconds: value.round(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
const Spacer(),
|
||||||
children: [
|
if (!compact) ...[
|
||||||
_RoundControlButton(
|
_RoundControlButton(
|
||||||
tooltip: playing ? 'Pause' : 'Play',
|
tooltip: 'Back 10s',
|
||||||
emphasized: true,
|
onPressed: () => player.seek(
|
||||||
onPressed: player.playOrPause,
|
_clampSeek(
|
||||||
icon: playing
|
position -
|
||||||
? Icons.pause_rounded
|
const Duration(seconds: 10),
|
||||||
: Icons.play_arrow_rounded,
|
duration,
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
_format(position),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
icon: Icons.replay_10_rounded,
|
||||||
' / ${_format(duration)}',
|
),
|
||||||
style: const TextStyle(
|
const SizedBox(width: 5),
|
||||||
color: Colors.white70,
|
_RoundControlButton(
|
||||||
fontWeight: FontWeight.w600,
|
tooltip: 'Forward 10s',
|
||||||
|
onPressed: () => player.seek(
|
||||||
|
_clampSeek(
|
||||||
|
position +
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
duration,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
icon: Icons.forward_10_rounded,
|
||||||
if (!compact) ...[
|
),
|
||||||
_RoundControlButton(
|
const SizedBox(width: 5),
|
||||||
tooltip: 'Back 10s',
|
],
|
||||||
onPressed: () => player.seek(
|
_RoundControlButton(
|
||||||
_clampSeek(
|
tooltip: muted ? 'Unmute' : 'Mute',
|
||||||
position -
|
onPressed: onToggleMute,
|
||||||
const Duration(seconds: 10),
|
icon: muted
|
||||||
duration,
|
? Icons.volume_off_rounded
|
||||||
),
|
: Icons.volume_up_rounded,
|
||||||
),
|
),
|
||||||
icon: Icons.replay_10_rounded,
|
const SizedBox(width: 5),
|
||||||
),
|
_RoundControlButton(
|
||||||
const SizedBox(width: 6),
|
tooltip: halfVolume
|
||||||
_RoundControlButton(
|
? 'Normal app volume'
|
||||||
tooltip: 'Forward 10s',
|
: 'Half app volume',
|
||||||
onPressed: () => player.seek(
|
selected: halfVolume,
|
||||||
_clampSeek(
|
onPressed: onToggleHalfVolume,
|
||||||
position +
|
icon: Icons.volume_down_rounded,
|
||||||
const Duration(seconds: 10),
|
),
|
||||||
duration,
|
const SizedBox(width: 5),
|
||||||
),
|
_RoundControlButton(
|
||||||
),
|
tooltip: loopVideo
|
||||||
icon: Icons.forward_10_rounded,
|
? 'Disable repeat'
|
||||||
),
|
: 'Repeat video',
|
||||||
const SizedBox(width: 6),
|
selected: loopVideo,
|
||||||
],
|
onPressed: onToggleLoop,
|
||||||
_RoundControlButton(
|
icon: loopVideo
|
||||||
tooltip: muted ? 'Unmute' : 'Mute',
|
? Icons.repeat_one_on_rounded
|
||||||
onPressed: onToggleMute,
|
: Icons.repeat_one_rounded,
|
||||||
icon: muted
|
),
|
||||||
? Icons.volume_off_rounded
|
const SizedBox(width: 5),
|
||||||
: Icons.volume_up_rounded,
|
if (!compact) ...[
|
||||||
),
|
_RoundControlButton(
|
||||||
const SizedBox(width: 6),
|
tooltip: coverVideo ? 'Fit' : 'Fill',
|
||||||
_RoundControlButton(
|
selected: coverVideo,
|
||||||
tooltip: halfVolume
|
onPressed: onToggleFit,
|
||||||
? 'Normal app volume'
|
icon: coverVideo
|
||||||
: 'Half app volume',
|
? Icons.fit_screen_rounded
|
||||||
selected: halfVolume,
|
: Icons.crop_free_rounded,
|
||||||
onPressed: onToggleHalfVolume,
|
),
|
||||||
icon: Icons.volume_down_rounded,
|
const SizedBox(width: 5),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 6),
|
_RoundControlButton(
|
||||||
_RoundControlButton(
|
tooltip: fullscreen
|
||||||
tooltip: loopVideo
|
? 'Exit fullscreen'
|
||||||
? 'Disable repeat'
|
: 'Fullscreen',
|
||||||
: 'Repeat video',
|
onPressed: onFullscreen,
|
||||||
selected: loopVideo,
|
icon: fullscreen
|
||||||
onPressed: onToggleLoop,
|
? Icons.fullscreen_exit_rounded
|
||||||
icon: loopVideo
|
: Icons.fullscreen_rounded,
|
||||||
? Icons.repeat_one_on_rounded
|
|
||||||
: Icons.repeat_one_rounded,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
if (!compact) ...[
|
|
||||||
_RoundControlButton(
|
|
||||||
tooltip: coverVideo ? 'Fit' : 'Fill',
|
|
||||||
selected: coverVideo,
|
|
||||||
onPressed: onToggleFit,
|
|
||||||
icon: coverVideo
|
|
||||||
? Icons.fit_screen_rounded
|
|
||||||
: Icons.crop_free_rounded,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
],
|
|
||||||
_RoundControlButton(
|
|
||||||
tooltip: fullscreen
|
|
||||||
? 'Exit fullscreen'
|
|
||||||
: 'Fullscreen',
|
|
||||||
onPressed: onFullscreen,
|
|
||||||
icon: fullscreen
|
|
||||||
? Icons.fullscreen_exit_rounded
|
|
||||||
: Icons.fullscreen_rounded,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,32 +19,115 @@ class ProviderHealthTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hint = ProviderQualityHint.from(
|
||||||
|
config: config,
|
||||||
|
health: health,
|
||||||
|
diagnostics: diagnostics,
|
||||||
|
);
|
||||||
return Card(
|
return Card(
|
||||||
child: ListTile(
|
child: ExpansionTile(
|
||||||
title: Text(config.name),
|
|
||||||
subtitle: Text(
|
|
||||||
[
|
|
||||||
'${config.apiType} | ${config.enabled ? 'enabled' : 'disabled'} | priority ${config.priority}',
|
|
||||||
if (health != null) 'Ping ${health!.pingMs} ms',
|
|
||||||
if (health?.apiVersion != null) 'API ${health!.apiVersion}',
|
|
||||||
if (health?.lastCheckedAt != null)
|
|
||||||
'Checked ${health!.lastCheckedAt}',
|
|
||||||
if (diagnostics != null)
|
|
||||||
'Last search: ${diagnostics!.lastResultCount} posts at ${diagnostics!.lastSearchAt}',
|
|
||||||
if (diagnostics?.lastErrorMessage != null)
|
|
||||||
diagnostics!.lastErrorMessage!,
|
|
||||||
if (health?.errorMessage != null) health!.errorMessage!,
|
|
||||||
].join('\n'),
|
|
||||||
maxLines: 7,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
leading: ProviderStatusBadge(health: health),
|
leading: ProviderStatusBadge(health: health),
|
||||||
|
title: Text(config.name),
|
||||||
|
subtitle: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
avatar: Icon(hint.icon, size: 16),
|
||||||
|
label: Text(hint.label),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
backgroundColor: hint.color(context).withValues(alpha: 0.16),
|
||||||
|
side:
|
||||||
|
BorderSide(color: hint.color(context).withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
Text('${config.apiType} | priority ${config.priority}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Check',
|
tooltip: 'Check',
|
||||||
onPressed: onCheck,
|
onPressed: onCheck,
|
||||||
icon: const Icon(Icons.network_check_rounded),
|
icon: const Icon(Icons.network_check_rounded),
|
||||||
),
|
),
|
||||||
|
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
[
|
||||||
|
'${config.enabled ? 'Enabled' : 'Disabled'} | ${config.baseUrl}',
|
||||||
|
if (health != null) 'Ping ${health!.pingMs} ms',
|
||||||
|
if (health?.apiVersion != null) 'API ${health!.apiVersion}',
|
||||||
|
if (health?.lastCheckedAt != null)
|
||||||
|
'Checked ${health!.lastCheckedAt}',
|
||||||
|
if (diagnostics != null)
|
||||||
|
'Last search: ${diagnostics!.lastResultCount} posts at ${diagnostics!.lastSearchAt}',
|
||||||
|
if (diagnostics?.lastErrorMessage != null)
|
||||||
|
diagnostics!.lastErrorMessage!,
|
||||||
|
if (health?.errorMessage != null) health!.errorMessage!,
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ProviderQualityHint {
|
||||||
|
const ProviderQualityHint._(this.label, this.icon, this._color);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color Function(ColorScheme scheme) _color;
|
||||||
|
|
||||||
|
Color color(BuildContext context) => _color(Theme.of(context).colorScheme);
|
||||||
|
|
||||||
|
static ProviderQualityHint from({
|
||||||
|
required ContentProviderConfig config,
|
||||||
|
required ProviderHealth? health,
|
||||||
|
required ProviderDiagnostics? diagnostics,
|
||||||
|
}) {
|
||||||
|
if (!config.enabled) {
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'Disabled',
|
||||||
|
Icons.pause_circle_outline_rounded,
|
||||||
|
(scheme) => scheme.outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (diagnostics?.lastErrorMessage != null ||
|
||||||
|
health?.status == ProviderStatus.offline) {
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'Search failing',
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
(scheme) => scheme.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (diagnostics != null && diagnostics.lastResultCount == 0) {
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'No recent results',
|
||||||
|
Icons.search_off_rounded,
|
||||||
|
(scheme) => scheme.tertiary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (health != null && health.pingMs > 2500) {
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'Slow',
|
||||||
|
Icons.speed_rounded,
|
||||||
|
(scheme) => scheme.secondary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (health != null || diagnostics != null) {
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'Healthy',
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
(scheme) => scheme.primary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ProviderQualityHint._(
|
||||||
|
'Not checked',
|
||||||
|
Icons.help_outline_rounded,
|
||||||
|
(scheme) => scheme.outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 0.4.0+4
|
version: 0.4.2+6
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -191,6 +191,29 @@ void main() {
|
|||||||
expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed');
|
expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all providers results are interleaved by provider priority', () async {
|
||||||
|
final repository = FakeProviderRepository()
|
||||||
|
..configs['a'] = config('a', 0)
|
||||||
|
..configs['b'] = config('b', 1)
|
||||||
|
..configs['c'] = config('c', 2);
|
||||||
|
final manager = ProviderManager(
|
||||||
|
repository,
|
||||||
|
FakeProviderFactory({
|
||||||
|
'a': FakeProvider('a', 'a', [post('a', '1'), post('a', '2')]),
|
||||||
|
'b': FakeProvider('b', 'b', [post('b', '1'), post('b', '2')]),
|
||||||
|
'c': FakeProvider('c', 'c', [post('c', '1')]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await manager.searchAcrossProviders(tags: [], page: 0)
|
||||||
|
as Success<List<Post>>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.data.map((item) => item.cacheKey),
|
||||||
|
['a:1', 'b:1', 'c:1', 'a:2', 'b:2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('enable disable provider persists config', () async {
|
test('enable disable provider persists config', () async {
|
||||||
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
||||||
final manager = ProviderManager(
|
final manager = ProviderManager(
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 120 KiB |