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 appBuildNumber = 4;
|
||||
const appDisplayVersion = '0.4.2';
|
||||
const appBuildNumber = 6;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../features/collections/presentation/collections_screen.dart';
|
||||
import '../features/favorites/presentation/favorites_screen.dart';
|
||||
import '../features/feed/presentation/feed_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_form_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,
|
||||
),
|
||||
),
|
||||
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(
|
||||
path: '/favorites',
|
||||
builder: (context, state) => const FavoritesScreen(),
|
||||
|
||||
@@ -97,8 +97,8 @@ class ProviderManager {
|
||||
providers.where((provider) => provider.id == providerId).toList();
|
||||
}
|
||||
|
||||
final posts = <Post>[];
|
||||
final seen = <String>{};
|
||||
final providerResults = <List<Post>>[];
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final providerPosts = await provider.searchPosts(
|
||||
@@ -108,9 +108,7 @@ class ProviderManager {
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
for (final post in providerPosts) {
|
||||
if (seen.add(post.cacheKey)) posts.add(post);
|
||||
}
|
||||
providerResults.add(providerPosts);
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
@@ -138,9 +136,45 @@ class ProviderManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
final posts = providerId == null
|
||||
? _interleaveProviderResults(providerResults, seen)
|
||||
: _flattenProviderResults(providerResults, seen);
|
||||
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(
|
||||
String providerId,
|
||||
String postId,
|
||||
|
||||
@@ -177,7 +177,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
? <String?>[null]
|
||||
: current.selectedProviderIds;
|
||||
final posts = <Post>[];
|
||||
final seen = <String>{};
|
||||
final providerResults = <List<Post>>[];
|
||||
for (final providerId in providerIds) {
|
||||
final result = refresh
|
||||
? await service.refresh(
|
||||
@@ -193,10 +193,33 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
topPeriod: current.topPeriodFilter,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 760),
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
),
|
||||
@@ -261,20 +262,36 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Set<String> favoriteKeys,
|
||||
MediaQualityMode qualityMode,
|
||||
) {
|
||||
return ListView(
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onVerticalDragEnd: (details) {
|
||||
if ((details.primaryVelocity ?? 0) > 900) _close(context);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onDoubleTap:
|
||||
isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onLongPress: isVideo
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
@@ -316,6 +333,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
_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(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
@@ -370,10 +447,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
|
||||
final query = similarTagsFor(post).join(' ');
|
||||
if (query.trim().isEmpty) return;
|
||||
ref.read(feedControllerProvider.notifier).search(query);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}');
|
||||
context.push('/post/${post.providerId}/${post.id}/similar', extra: 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();
|
||||
}
|
||||
|
||||
class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
Player? _player;
|
||||
VideoController? _controller;
|
||||
late List<String> _imageUrls;
|
||||
@@ -52,6 +53,7 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
int _imageIndex = 0;
|
||||
int _videoIndex = 0;
|
||||
bool _controlsVisible = true;
|
||||
bool _inFullscreen = false;
|
||||
late bool _coverVideo;
|
||||
late bool _muted;
|
||||
late bool _loopVideo;
|
||||
@@ -98,8 +100,12 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
if (_isSwf(widget.post)) {
|
||||
return _SwfMediaViewer(
|
||||
post: widget.post,
|
||||
@@ -109,6 +115,19 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
}
|
||||
|
||||
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(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
|
||||
@@ -347,22 +366,26 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
|
||||
Future<void> _openFullscreen(BuildContext context) async {
|
||||
final player = _player;
|
||||
final position = player?.state.position ?? Duration.zero;
|
||||
final wasPlaying = player?.state.playing ?? false;
|
||||
if (wasPlaying) await player?.pause();
|
||||
final controller = _controller;
|
||||
if (player == null || controller == null) return;
|
||||
setState(() => _inFullscreen = true);
|
||||
if (!context.mounted) return;
|
||||
final result = await Navigator.of(context, rootNavigator: true)
|
||||
.push<VideoPlaybackSnapshot>(
|
||||
PageRouteBuilder(
|
||||
opaque: true,
|
||||
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
|
||||
post: widget.post,
|
||||
startAt: position,
|
||||
autoplay: wasPlaying,
|
||||
player: player,
|
||||
controller: controller,
|
||||
aspectRatio: widget.post.width > 0 && widget.post.height > 0
|
||||
? widget.post.width / widget.post.height
|
||||
: 16 / 9,
|
||||
loopVideo: _loopVideo,
|
||||
muted: _muted,
|
||||
halfVolume: _halfVolume,
|
||||
coverVideo: _coverVideo,
|
||||
errorMessage: _videoError,
|
||||
onRetry: _retryVideo,
|
||||
),
|
||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
||||
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;
|
||||
setState(() {
|
||||
_muted = result.muted;
|
||||
@@ -382,11 +406,8 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
await player.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
);
|
||||
await player.seek(result.position);
|
||||
if (result.playing) await player.play();
|
||||
return;
|
||||
}
|
||||
if (wasPlaying) await player?.play();
|
||||
}
|
||||
|
||||
VideoPlaybackSnapshot _snapshot() {
|
||||
@@ -712,51 +733,58 @@ class VideoPlaybackSnapshot {
|
||||
|
||||
class _FullscreenVideoPage extends StatefulWidget {
|
||||
const _FullscreenVideoPage({
|
||||
required this.post,
|
||||
required this.startAt,
|
||||
required this.autoplay,
|
||||
required this.player,
|
||||
required this.controller,
|
||||
required this.aspectRatio,
|
||||
required this.loopVideo,
|
||||
required this.muted,
|
||||
required this.halfVolume,
|
||||
required this.coverVideo,
|
||||
required this.errorMessage,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final Duration startAt;
|
||||
final bool autoplay;
|
||||
final Player player;
|
||||
final VideoController controller;
|
||||
final double aspectRatio;
|
||||
final bool loopVideo;
|
||||
final bool muted;
|
||||
final bool halfVolume;
|
||||
final bool coverVideo;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
|
||||
}
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snapshot = VideoPlaybackSnapshot(
|
||||
position: widget.startAt,
|
||||
playing: widget.autoplay,
|
||||
muted: widget.muted,
|
||||
halfVolume: widget.halfVolume,
|
||||
loopVideo: widget.loopVideo,
|
||||
coverVideo: widget.coverVideo,
|
||||
);
|
||||
_coverVideo = widget.coverVideo;
|
||||
_muted = widget.muted;
|
||||
_loopVideo = widget.loopVideo;
|
||||
_halfVolume = widget.halfVolume;
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
_scheduleControlsHide();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
super.dispose();
|
||||
@@ -768,24 +796,77 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
Navigator.of(context, rootNavigator: true).pop(_snapshot);
|
||||
Navigator.of(context, rootNavigator: true).pop(_snapshot());
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
body: Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
_CloseVideoIntent: CallbackAction<_CloseVideoIntent>(
|
||||
onInvoke: (_) {
|
||||
Navigator.of(context, rootNavigator: true).pop(_snapshot());
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.player.playOrPause();
|
||||
_showControls();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PostMediaViewer(
|
||||
post: widget.post,
|
||||
child: _VideoSurface(
|
||||
player: widget.player,
|
||||
controller: widget.controller,
|
||||
aspectRatio: widget.aspectRatio,
|
||||
controlsVisible: _controlsVisible,
|
||||
coverVideo: _coverVideo,
|
||||
muted: _muted,
|
||||
loopVideo: _loopVideo,
|
||||
halfVolume: _halfVolume,
|
||||
fullscreen: true,
|
||||
initialPosition: widget.startAt,
|
||||
autoplay: widget.autoplay,
|
||||
initialLoop: widget.loopVideo,
|
||||
initialMuted: widget.muted,
|
||||
initialHalfVolume: widget.halfVolume,
|
||||
initialCoverVideo: widget.coverVideo,
|
||||
qualityMode: MediaQualityMode.highQuality,
|
||||
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot,
|
||||
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(
|
||||
@@ -794,8 +875,10 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
child: SafeArea(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Close fullscreen',
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(_snapshot),
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(_snapshot()),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
@@ -803,8 +886,49 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1039,17 +1163,17 @@ class _RoundControlButton extends StatelessWidget {
|
||||
message: tooltip,
|
||||
child: InkResponse(
|
||||
onTap: onPressed,
|
||||
radius: emphasized ? 30 : 24,
|
||||
radius: emphasized ? 23 : 21,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
width: emphasized ? 48 : 40,
|
||||
height: emphasized ? 48 : 40,
|
||||
width: emphasized ? 38 : 34,
|
||||
height: emphasized ? 38 : 34,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
shape: BoxShape.circle,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.78),
|
||||
Colors.black.withValues(alpha: 0.22),
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
fullscreen ? 22 : 12,
|
||||
12,
|
||||
fullscreen ? 22 : 12,
|
||||
fullscreen ? 22 : 12,
|
||||
child: SafeArea(
|
||||
minimum: EdgeInsets.fromLTRB(
|
||||
fullscreen ? 14 : 8,
|
||||
0,
|
||||
fullscreen ? 14 : 8,
|
||||
fullscreen ? 10 : 8,
|
||||
),
|
||||
child: StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
@@ -1129,43 +1253,22 @@ class _VideoControls extends StatelessWidget {
|
||||
.toDouble();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 980;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.58),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black54,
|
||||
blurRadius: 26,
|
||||
offset: Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 10 : 14,
|
||||
8,
|
||||
compact ? 10 : 14,
|
||||
compact ? 10 : 12,
|
||||
),
|
||||
child: Column(
|
||||
final compact = constraints.maxWidth < 560;
|
||||
final tiny = constraints.maxWidth < 390;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: scheme.primary,
|
||||
inactiveTrackColor:
|
||||
Colors.white.withValues(alpha: 0.22),
|
||||
trackHeight: 5,
|
||||
Colors.white.withValues(alpha: 0.24),
|
||||
trackHeight: 3,
|
||||
thumbColor: scheme.primary,
|
||||
overlayColor:
|
||||
scheme.primary.withValues(alpha: 0.2),
|
||||
scheme.primary.withValues(alpha: 0.18),
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 7,
|
||||
enabledThumbRadius: 5,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
@@ -1190,19 +1293,15 @@ class _VideoControls extends StatelessWidget {
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_format(position),
|
||||
tiny
|
||||
? _format(position)
|
||||
: '${_format(position)} / ${_format(duration)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' / ${_format(duration)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -1218,7 +1317,7 @@ class _VideoControls extends StatelessWidget {
|
||||
),
|
||||
icon: Icons.replay_10_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
_RoundControlButton(
|
||||
tooltip: 'Forward 10s',
|
||||
onPressed: () => player.seek(
|
||||
@@ -1230,7 +1329,7 @@ class _VideoControls extends StatelessWidget {
|
||||
),
|
||||
icon: Icons.forward_10_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
_RoundControlButton(
|
||||
tooltip: muted ? 'Unmute' : 'Mute',
|
||||
@@ -1239,7 +1338,7 @@ class _VideoControls extends StatelessWidget {
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
_RoundControlButton(
|
||||
tooltip: halfVolume
|
||||
? 'Normal app volume'
|
||||
@@ -1248,7 +1347,7 @@ class _VideoControls extends StatelessWidget {
|
||||
onPressed: onToggleHalfVolume,
|
||||
icon: Icons.volume_down_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
_RoundControlButton(
|
||||
tooltip: loopVideo
|
||||
? 'Disable repeat'
|
||||
@@ -1259,7 +1358,7 @@ class _VideoControls extends StatelessWidget {
|
||||
? Icons.repeat_one_on_rounded
|
||||
: Icons.repeat_one_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
if (!compact) ...[
|
||||
_RoundControlButton(
|
||||
tooltip: coverVideo ? 'Fit' : 'Fill',
|
||||
@@ -1269,7 +1368,7 @@ class _VideoControls extends StatelessWidget {
|
||||
? Icons.fit_screen_rounded
|
||||
: Icons.crop_free_rounded,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
_RoundControlButton(
|
||||
tooltip: fullscreen
|
||||
@@ -1283,8 +1382,6 @@ class _VideoControls extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,12 +19,43 @@ class ProviderHealthTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hint = ProviderQualityHint.from(
|
||||
config: config,
|
||||
health: health,
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
return Card(
|
||||
child: ListTile(
|
||||
child: ExpansionTile(
|
||||
leading: ProviderStatusBadge(health: health),
|
||||
title: Text(config.name),
|
||||
subtitle: Text(
|
||||
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(
|
||||
tooltip: 'Check',
|
||||
onPressed: onCheck,
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
[
|
||||
'${config.apiType} | ${config.enabled ? 'enabled' : 'disabled'} | priority ${config.priority}',
|
||||
'${config.enabled ? 'Enabled' : 'Disabled'} | ${config.baseUrl}',
|
||||
if (health != null) 'Ping ${health!.pingMs} ms',
|
||||
if (health?.apiVersion != null) 'API ${health!.apiVersion}',
|
||||
if (health?.lastCheckedAt != null)
|
||||
@@ -35,16 +66,68 @@ class ProviderHealthTile extends StatelessWidget {
|
||||
diagnostics!.lastErrorMessage!,
|
||||
if (health?.errorMessage != null) health!.errorMessage!,
|
||||
].join('\n'),
|
||||
maxLines: 7,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: ProviderStatusBadge(health: health),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Check',
|
||||
onPressed: onCheck,
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 0.4.0+4
|
||||
version: 0.4.2+6
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -191,6 +191,29 @@ void main() {
|
||||
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 {
|
||||
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
||||
final manager = ProviderManager(
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 120 KiB |