Add similar posts, provider hints and app icon

This commit is contained in:
2026-05-24 16:53:22 +03:00
parent 73a991e3a8
commit 52f2c6669c
17 changed files with 769 additions and 295 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '0.4.0'; const appDisplayVersion = '0.4.2';
const appBuildNumber = 4; const appBuildNumber = 6;
+9
View File
@@ -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(),
+38 -4
View File
@@ -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,20 +262,36 @@ class PostDetailsScreen extends ConsumerWidget {
Set<String> favoriteKeys, Set<String> favoriteKeys,
MediaQualityMode qualityMode, 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), padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
children: [ 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( constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62, maxHeight: MediaQuery.sizeOf(context).height * 0.62,
), ),
child: Center( child: Center(
child: PostMediaViewer( child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post, post: post,
qualityMode: qualityMode, qualityMode: qualityMode,
), ),
), ),
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
PostActionBar( PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey), isFavorite: favoriteKeys.contains(post.cacheKey),
@@ -316,6 +333,7 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
_CommentsSection(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,24 +796,77 @@ 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(
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: [ children: [
Positioned.fill( Positioned.fill(
child: PostMediaViewer( child: _VideoSurface(
post: widget.post, player: widget.player,
controller: widget.controller,
aspectRatio: widget.aspectRatio,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
loopVideo: _loopVideo,
halfVolume: _halfVolume,
fullscreen: true, fullscreen: true,
initialPosition: widget.startAt, errorMessage: widget.errorMessage,
autoplay: widget.autoplay, onInteract: _showControls,
initialLoop: widget.loopVideo, onRetry: widget.onRetry,
initialMuted: widget.muted, onToggleFit: () {
initialHalfVolume: widget.halfVolume, setState(() => _coverVideo = !_coverVideo);
initialCoverVideo: widget.coverVideo, _showControls();
qualityMode: MediaQualityMode.highQuality, },
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot, 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( Positioned(
@@ -794,8 +875,10 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
child: SafeArea( child: SafeArea(
child: IconButton.filledTonal( child: IconButton.filledTonal(
tooltip: 'Close fullscreen', tooltip: 'Close fullscreen',
onPressed: () => onPressed: () => Navigator.of(
Navigator.of(context, rootNavigator: true).pop(_snapshot), context,
rootNavigator: true,
).pop(_snapshot()),
icon: const Icon(Icons.close_rounded), 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 { 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,43 +1253,22 @@ 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),
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(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SliderTheme( SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
activeTrackColor: scheme.primary, activeTrackColor: scheme.primary,
inactiveTrackColor: inactiveTrackColor:
Colors.white.withValues(alpha: 0.22), Colors.white.withValues(alpha: 0.24),
trackHeight: 5, trackHeight: 3,
thumbColor: scheme.primary, thumbColor: scheme.primary,
overlayColor: overlayColor:
scheme.primary.withValues(alpha: 0.2), scheme.primary.withValues(alpha: 0.18),
thumbShape: const RoundSliderThumbShape( thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 7, enabledThumbRadius: 5,
), ),
), ),
child: Slider( child: Slider(
@@ -1190,19 +1293,15 @@ class _VideoControls extends StatelessWidget {
? Icons.pause_rounded ? Icons.pause_rounded
: Icons.play_arrow_rounded, : Icons.play_arrow_rounded,
), ),
const SizedBox(width: 10), const SizedBox(width: 8),
Text( Text(
_format(position), tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w800, fontSize: 12,
), fontWeight: FontWeight.w700,
),
Text(
' / ${_format(duration)}',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
), ),
), ),
const Spacer(), const Spacer(),
@@ -1218,7 +1317,7 @@ class _VideoControls extends StatelessWidget {
), ),
icon: Icons.replay_10_rounded, icon: Icons.replay_10_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
_RoundControlButton( _RoundControlButton(
tooltip: 'Forward 10s', tooltip: 'Forward 10s',
onPressed: () => player.seek( onPressed: () => player.seek(
@@ -1230,7 +1329,7 @@ class _VideoControls extends StatelessWidget {
), ),
icon: Icons.forward_10_rounded, icon: Icons.forward_10_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
], ],
_RoundControlButton( _RoundControlButton(
tooltip: muted ? 'Unmute' : 'Mute', tooltip: muted ? 'Unmute' : 'Mute',
@@ -1239,7 +1338,7 @@ class _VideoControls extends StatelessWidget {
? Icons.volume_off_rounded ? Icons.volume_off_rounded
: Icons.volume_up_rounded, : Icons.volume_up_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
_RoundControlButton( _RoundControlButton(
tooltip: halfVolume tooltip: halfVolume
? 'Normal app volume' ? 'Normal app volume'
@@ -1248,7 +1347,7 @@ class _VideoControls extends StatelessWidget {
onPressed: onToggleHalfVolume, onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded, icon: Icons.volume_down_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
_RoundControlButton( _RoundControlButton(
tooltip: loopVideo tooltip: loopVideo
? 'Disable repeat' ? 'Disable repeat'
@@ -1259,7 +1358,7 @@ class _VideoControls extends StatelessWidget {
? Icons.repeat_one_on_rounded ? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded, : Icons.repeat_one_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
if (!compact) ...[ if (!compact) ...[
_RoundControlButton( _RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill', tooltip: coverVideo ? 'Fit' : 'Fill',
@@ -1269,7 +1368,7 @@ class _VideoControls extends StatelessWidget {
? Icons.fit_screen_rounded ? Icons.fit_screen_rounded
: Icons.crop_free_rounded, : Icons.crop_free_rounded,
), ),
const SizedBox(width: 6), const SizedBox(width: 5),
], ],
_RoundControlButton( _RoundControlButton(
tooltip: fullscreen tooltip: fullscreen
@@ -1283,8 +1382,6 @@ class _VideoControls extends StatelessWidget {
], ],
), ),
], ],
),
),
); );
}, },
); );
@@ -19,12 +19,43 @@ 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(
leading: ProviderStatusBadge(health: health),
title: Text(config.name), 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 != null) 'Ping ${health!.pingMs} ms',
if (health?.apiVersion != null) 'API ${health!.apiVersion}', if (health?.apiVersion != null) 'API ${health!.apiVersion}',
if (health?.lastCheckedAt != null) if (health?.lastCheckedAt != null)
@@ -35,16 +66,68 @@ class ProviderHealthTile extends StatelessWidget {
diagnostics!.lastErrorMessage!, diagnostics!.lastErrorMessage!,
if (health?.errorMessage != null) health!.errorMessage!, if (health?.errorMessage != null) health!.errorMessage!,
].join('\n'), ].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 -1
View File
@@ -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"
+23
View File
@@ -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(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 120 KiB