Add QoL feed, download and quality improvements

This commit is contained in:
2026-05-24 13:02:06 +03:00
parent 7f79addb14
commit ec29ed21d2
25 changed files with 942 additions and 62 deletions
+110
View File
@@ -36,11 +36,121 @@ class GelRuleApp extends ConsumerWidget {
orElse: () => ThemeMode.dark, orElse: () => ThemeMode.dark,
), ),
routerConfig: router, routerConfig: router,
builder: (context, child) => _DownloadOverlay(child: child),
), ),
); );
} }
} }
class _DownloadOverlay extends ConsumerWidget {
const _DownloadOverlay({required this.child});
final Widget? child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(downloadTasksProvider).value ?? const [];
final visible = tasks
.where((task) =>
task.status == DownloadTaskStatus.running ||
task.status == DownloadTaskStatus.queued ||
task.status == DownloadTaskStatus.failed ||
task.status == DownloadTaskStatus.completed)
.toList();
return Stack(
children: [
if (child != null) child!,
if (visible.isNotEmpty)
Positioned(
right: 16,
bottom: 16,
child: SafeArea(
child: _DownloadPanel(tasks: visible.take(3).toList()),
),
),
],
);
}
}
class _DownloadPanel extends ConsumerWidget {
const _DownloadPanel({required this.tasks});
final List<DownloadTask> tasks;
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(16),
color: scheme.surface,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final task in tasks) ...[
Row(
children: [
Icon(_icon(task.status), size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
task.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (task.status == DownloadTaskStatus.failed)
IconButton(
tooltip: 'Retry',
onPressed: () => ref
.read(downloadManagerServiceProvider)
.retry(task.id),
icon: const Icon(Icons.refresh_rounded),
),
],
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: task.status == DownloadTaskStatus.running
? task.progress.clamp(0, 1)
: task.status == DownloadTaskStatus.completed
? 1
: null,
),
if (task.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
task.error!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.error),
),
),
if (task != tasks.last) const SizedBox(height: 10),
],
],
),
),
),
);
}
IconData _icon(DownloadTaskStatus status) {
return switch (status) {
DownloadTaskStatus.completed => Icons.download_done_rounded,
DownloadTaskStatus.failed => Icons.error_outline_rounded,
DownloadTaskStatus.canceled => Icons.cancel_rounded,
_ => Icons.download_rounded,
};
}
}
final appSettingsProvider = FutureProvider<AppSettings>((ref) { final appSettingsProvider = FutureProvider<AppSettings>((ref) {
return ref.watch(settingsServiceProvider).getSettings().then( return ref.watch(settingsServiceProvider).getSettings().then(
(result) => result.fold( (result) => result.fold(
+3
View File
@@ -1,6 +1,7 @@
export 'di/backend_providers.dart'; export 'di/backend_providers.dart';
export 'models/collection.dart'; export 'models/collection.dart';
export 'models/content_provider_config.dart'; export 'models/content_provider_config.dart';
export 'models/download_task.dart';
export 'models/favorite.dart'; export 'models/favorite.dart';
export 'models/post.dart'; export 'models/post.dart';
export 'models/post_comment.dart'; export 'models/post_comment.dart';
@@ -14,6 +15,7 @@ export 'providers/content_provider.dart';
export 'providers/provider_manager.dart'; export 'providers/provider_manager.dart';
export 'services/collection_service.dart'; export 'services/collection_service.dart';
export 'services/download_service.dart'; export 'services/download_service.dart';
export 'services/download_manager_service.dart';
export 'services/favorite_service.dart'; export 'services/favorite_service.dart';
export 'services/feed_service.dart'; export 'services/feed_service.dart';
export 'services/provider_check_service.dart'; export 'services/provider_check_service.dart';
@@ -21,3 +23,4 @@ export 'services/search_service.dart';
export 'services/settings_service.dart'; export 'services/settings_service.dart';
export 'services/viewed_history_service.dart'; export 'services/viewed_history_service.dart';
export 'utils/smart_blacklist.dart'; export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart';
+11
View File
@@ -9,6 +9,7 @@ import '../../core/http/network_info.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
import '../providers/provider_factory.dart'; import '../providers/provider_factory.dart';
import '../providers/provider_manager.dart'; import '../providers/provider_manager.dart';
import '../models/download_task.dart';
import '../repositories/collection_repository.dart'; import '../repositories/collection_repository.dart';
import '../repositories/favorite_repository.dart'; import '../repositories/favorite_repository.dart';
import '../repositories/post_repository.dart'; import '../repositories/post_repository.dart';
@@ -17,6 +18,7 @@ import '../repositories/search_repository.dart';
import '../repositories/viewed_post_repository.dart'; import '../repositories/viewed_post_repository.dart';
import '../services/collection_service.dart'; import '../services/collection_service.dart';
import '../services/download_service.dart'; import '../services/download_service.dart';
import '../services/download_manager_service.dart';
import '../services/favorite_service.dart'; import '../services/favorite_service.dart';
import '../services/feed_service.dart'; import '../services/feed_service.dart';
import '../services/provider_check_service.dart'; import '../services/provider_check_service.dart';
@@ -147,3 +149,12 @@ final settingsServiceProvider = Provider<SettingsService>((ref) {
final downloadServiceProvider = Provider<DownloadService>((ref) { final downloadServiceProvider = Provider<DownloadService>((ref) {
return DownloadService(); return DownloadService();
}); });
final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
return DownloadManagerService(ref.watch(downloadServiceProvider));
});
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
final manager = ref.watch(downloadManagerServiceProvider);
return manager.stream;
});
+46
View File
@@ -0,0 +1,46 @@
import 'post.dart';
enum DownloadTaskStatus {
queued,
running,
completed,
failed,
canceled,
}
class DownloadTask {
const DownloadTask({
required this.id,
required this.post,
required this.fileName,
required this.progress,
required this.status,
this.savedPath,
this.error,
});
final String id;
final Post post;
final String fileName;
final double progress;
final DownloadTaskStatus status;
final String? savedPath;
final String? error;
DownloadTask copyWith({
double? progress,
DownloadTaskStatus? status,
String? savedPath,
String? error,
}) {
return DownloadTask(
id: id,
post: post,
fileName: fileName,
progress: progress ?? this.progress,
status: status ?? this.status,
savedPath: savedPath ?? this.savedPath,
error: error,
);
}
}
+15 -1
View File
@@ -147,10 +147,24 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
} }
List<String> _topTags(TopPeriodFilter period) { List<String> _topTags(TopPeriodFilter period) {
final now = DateTime.now();
return switch (period) { return switch (period) {
TopPeriodFilter.none => const [], TopPeriodFilter.none => const [],
TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'], TopPeriodFilter.month => [
'order:score',
'date:>${_date(now.subtract(const Duration(days: 31)))}',
],
TopPeriodFilter.year => [
'order:score',
'date:>${_date(DateTime(now.year - 1, now.month, now.day))}',
],
TopPeriodFilter.allTime => const ['order:score'], TopPeriodFilter.allTime => const ['order:score'],
}; };
} }
String _date(DateTime value) {
return '${value.year.toString().padLeft(4, '0')}-'
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}
} }
+16 -2
View File
@@ -168,13 +168,27 @@ class E621Provider
} }
List<String> _topTags(TopPeriodFilter period) { List<String> _topTags(TopPeriodFilter period) {
final now = DateTime.now();
return switch (period) { return switch (period) {
TopPeriodFilter.none => const [], TopPeriodFilter.none => const [],
TopPeriodFilter.month => const ['order:rank'], TopPeriodFilter.month => [
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'], 'order:score',
'date:>${_date(now.subtract(const Duration(days: 31)))}',
],
TopPeriodFilter.year => [
'order:score',
'date:>${_date(DateTime(now.year - 1, now.month, now.day))}',
],
TopPeriodFilter.allTime => const ['order:score'],
}; };
} }
String _date(DateTime value) {
return '${value.year.toString().padLeft(4, '0')}-'
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}
int _int(dynamic value) { int _int(dynamic value) {
if (value is int) return value; if (value is int) return value;
if (value is num) return value.toInt(); if (value is num) return value.toInt();
+2 -4
View File
@@ -140,10 +140,8 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
List<String> _topTags(TopPeriodFilter period) { List<String> _topTags(TopPeriodFilter period) {
return switch (period) { return switch (period) {
TopPeriodFilter.none => const [], TopPeriodFilter.none => const [],
TopPeriodFilter.month || TopPeriodFilter.month || TopPeriodFilter.year => const [],
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['sort:score:desc'],
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
}; };
} }
+2 -4
View File
@@ -166,10 +166,8 @@ class MoebooruProvider
List<String> _topTags(TopPeriodFilter period) { List<String> _topTags(TopPeriodFilter period) {
return switch (period) { return switch (period) {
TopPeriodFilter.none => const [], TopPeriodFilter.none => const [],
TopPeriodFilter.month || TopPeriodFilter.month || TopPeriodFilter.year => const [],
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'],
TopPeriodFilter.allTime =>
const ['order:score'],
}; };
} }
+2 -4
View File
@@ -22,10 +22,8 @@ class Rule34Provider extends GelbooruProvider {
}) async { }) async {
final topTags = switch (topPeriod) { final topTags = switch (topPeriod) {
TopPeriodFilter.none => const <String>[], TopPeriodFilter.none => const <String>[],
TopPeriodFilter.month || TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['sort:score:desc'],
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
}; };
final response = await dio.get<dynamic>( final response = await dio.get<dynamic>(
'/index.php', '/index.php',
@@ -70,6 +70,23 @@ class CollectionRepository {
}); });
} }
Future<Result<void>> addPosts(String collectionId, List<Post> posts) async {
if (posts.isEmpty) return const Success(null);
await _postRepository.cachePosts(posts);
return _databaseService.safeWrite((isar) async {
final now = DateTime.now();
await isar.collectionPostEntitys.putAll([
for (final post in posts)
CollectionPostEntity()
..linkKey = '$collectionId:${post.providerId}:${post.id}'
..collectionId = collectionId
..postId = post.id
..providerId = post.providerId
..addedAt = now,
]);
});
}
Future<Result<void>> removePost( Future<Result<void>> removePost(
String collectionId, String collectionId,
String postId, String postId,
@@ -56,6 +56,13 @@ class CollectionService {
return _repository.addPost(collectionId, post); return _repository.addPost(collectionId, post);
} }
Future<Result<void>> addPostsToCollection(
String collectionId,
List<Post> posts,
) {
return _repository.addPosts(collectionId, posts);
}
Future<Result<void>> removePostFromCollection( Future<Result<void>> removePostFromCollection(
String collectionId, String collectionId,
String postId, String postId,
@@ -0,0 +1,114 @@
import 'dart:async';
import '../models/download_task.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
import 'download_service.dart';
class DownloadManagerService {
DownloadManagerService(this._downloadService);
final DownloadService _downloadService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
Future<DownloadTask> start(Post post) async {
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
final task = DownloadTask(
id: id,
post: post,
fileName: _suggestedFileName(post),
progress: 0,
status: DownloadTaskStatus.queued,
);
_set(task);
unawaited(_run(task));
return task;
}
Future<void> retry(String taskId) async {
final existing = _tasks[taskId];
if (existing == null) return;
final task = existing.copyWith(
progress: 0,
status: DownloadTaskStatus.queued,
error: null,
);
_set(task);
await _run(task);
}
void cancel(String taskId) {
final existing = _tasks[taskId];
if (existing == null) return;
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
}
Future<void> _run(DownloadTask task) async {
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
final saved = await _downloadService.downloadPost(
task.post,
onProgress: (received, total) {
if (total <= 0) return;
final current = _tasks[task.id];
if (current == null ||
current.status == DownloadTaskStatus.canceled) {
return;
}
_set(current.copyWith(progress: received / total));
},
);
final current = _tasks[task.id];
if (current == null || current.status == DownloadTaskStatus.canceled) {
return;
}
_set(
current.copyWith(
progress: 1,
status: DownloadTaskStatus.completed,
savedPath: saved,
),
);
_scheduleAutoRemove(task.id, const Duration(seconds: 6));
} catch (error) {
final current = _tasks[task.id];
if (current == null || current.status == DownloadTaskStatus.canceled) {
return;
}
_set(
current.copyWith(
status: DownloadTaskStatus.failed,
error: error.toString(),
),
);
}
}
void _set(DownloadTask task) {
_tasks[task.id] = task;
_controller.add(tasks);
}
void _scheduleAutoRemove(String taskId, Duration delay) {
Future<void>.delayed(delay, () {
final current = _tasks[taskId];
if (current?.status != DownloadTaskStatus.completed) return;
_tasks.remove(taskId);
_controller.add(tasks);
});
}
String _suggestedFileName(Post post) {
final url = MediaUrlSelector.download(post) ?? post.fileUrl;
final parsed = Uri.tryParse(url);
final last =
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
if (last.contains('.')) return last;
return '${post.providerId}_${post.id}';
}
}
+2 -4
View File
@@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../utils/media_quality.dart';
class DownloadService { class DownloadService {
DownloadService({Dio? dio}) : _dio = dio ?? Dio(); DownloadService({Dio? dio}) : _dio = dio ?? Dio();
@@ -43,10 +44,7 @@ class DownloadService {
} }
String? _downloadUrl(Post post) { String? _downloadUrl(Post post) {
for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) { return MediaUrlSelector.download(post);
if (url.trim().isNotEmpty) return url;
}
return null;
} }
String _fileName(Post post, String url) { String _fileName(Post post, String url) {
+20 -1
View File
@@ -78,7 +78,7 @@ class FeedService {
final viewedKeys = viewedKeysResult is Success<Set<String>> final viewedKeys = viewedKeysResult is Success<Set<String>>
? viewedKeysResult.data ? viewedKeysResult.data
: <String>{}; : <String>{};
final filteredPosts = posts final filteredPosts = _applyTopPeriod(posts, topPeriod)
.where((post) => postMatchesRequestedTags(post, tags)) .where((post) => postMatchesRequestedTags(post, tags))
.where((post) => postPassesTagFilters(post, settings)) .where((post) => postPassesTagFilters(post, settings))
.where((post) => .where((post) =>
@@ -90,6 +90,25 @@ class FeedService {
); );
return Success(filteredPosts); return Success(filteredPosts);
} }
List<Post> _applyTopPeriod(List<Post> posts, TopPeriodFilter period) {
if (period == TopPeriodFilter.none) return posts;
final sorted = [...posts]..sort((a, b) => b.score.compareTo(a.score));
if (period == TopPeriodFilter.allTime) return sorted;
final now = DateTime.now();
final minDate = period == TopPeriodFilter.month
? now.subtract(const Duration(days: 31))
: DateTime(now.year - 1, now.month, now.day);
final inPeriod = sorted
.where((post) =>
post.createdAt.isAfter(minDate) &&
post.createdAt.isBefore(now.add(const Duration(days: 1))))
.toList(growable: false);
return inPeriod.isEmpty ? sorted : inPeriod;
}
} }
bool postMatchesRequestedTags(Post post, List<String> requestedTags) { bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
@@ -28,7 +28,13 @@ class AppSettings {
required this.whitelistedTags, required this.whitelistedTags,
required this.smartBlacklistRules, required this.smartBlacklistRules,
required this.hideViewedPosts, required this.hideViewedPosts,
required this.mediaQualityMode,
required this.lastFeedTags,
required this.lastFeedProviderIds,
required this.lastFeedTopPeriod,
required this.lastFeedScrollOffset,
this.defaultRatingFilter, this.defaultRatingFilter,
this.lastFeedRating,
}); });
final List<String> enabledProviderIds; final List<String> enabledProviderIds;
@@ -48,7 +54,13 @@ class AppSettings {
final List<String> whitelistedTags; final List<String> whitelistedTags;
final List<String> smartBlacklistRules; final List<String> smartBlacklistRules;
final bool hideViewedPosts; final bool hideViewedPosts;
final String mediaQualityMode;
final List<String> lastFeedTags;
final List<String> lastFeedProviderIds;
final String lastFeedTopPeriod;
final double lastFeedScrollOffset;
final String? defaultRatingFilter; final String? defaultRatingFilter;
final String? lastFeedRating;
static const defaults = AppSettings( static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'], enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
@@ -68,7 +80,13 @@ class AppSettings {
whitelistedTags: [], whitelistedTags: [],
smartBlacklistRules: [], smartBlacklistRules: [],
hideViewedPosts: false, hideViewedPosts: false,
mediaQualityMode: 'auto',
lastFeedTags: [],
lastFeedProviderIds: [],
lastFeedTopPeriod: 'none',
lastFeedScrollOffset: 0,
defaultRatingFilter: null, defaultRatingFilter: null,
lastFeedRating: null,
); );
AppSettings copyWith({ AppSettings copyWith({
@@ -89,7 +107,14 @@ class AppSettings {
List<String>? whitelistedTags, List<String>? whitelistedTags,
List<String>? smartBlacklistRules, List<String>? smartBlacklistRules,
bool? hideViewedPosts, bool? hideViewedPosts,
String? mediaQualityMode,
List<String>? lastFeedTags,
List<String>? lastFeedProviderIds,
String? lastFeedTopPeriod,
double? lastFeedScrollOffset,
String? defaultRatingFilter, String? defaultRatingFilter,
String? lastFeedRating,
bool clearLastFeedRating = false,
}) { }) {
return AppSettings( return AppSettings(
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
@@ -111,7 +136,14 @@ class AppSettings {
whitelistedTags: whitelistedTags ?? this.whitelistedTags, whitelistedTags: whitelistedTags ?? this.whitelistedTags,
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules, smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts, hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds,
lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod,
lastFeedScrollOffset: lastFeedScrollOffset ?? this.lastFeedScrollOffset,
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter, defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
); );
} }
@@ -133,7 +165,13 @@ class AppSettings {
'whitelistedTags': whitelistedTags, 'whitelistedTags': whitelistedTags,
'smartBlacklistRules': smartBlacklistRules, 'smartBlacklistRules': smartBlacklistRules,
'hideViewedPosts': hideViewedPosts, 'hideViewedPosts': hideViewedPosts,
'mediaQualityMode': mediaQualityMode,
'lastFeedTags': lastFeedTags,
'lastFeedProviderIds': lastFeedProviderIds,
'lastFeedTopPeriod': lastFeedTopPeriod,
'lastFeedScrollOffset': lastFeedScrollOffset,
'defaultRatingFilter': defaultRatingFilter, 'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
}; };
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings( factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -178,7 +216,24 @@ class AppSettings {
), ),
hideViewedPosts: hideViewedPosts:
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts, (json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
mediaQualityMode:
(json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode,
lastFeedTags: List<String>.from(
(json['lastFeedTags'] as List?) ?? defaults.lastFeedTags,
),
lastFeedProviderIds: List<String>.from(
(json['lastFeedProviderIds'] as List?) ??
(json['selectedFeedProviderIds'] as List?) ??
defaults.lastFeedProviderIds,
),
lastFeedTopPeriod: (json['lastFeedTopPeriod'] as String?) ??
(json['defaultTopPeriodFilter'] as String?) ??
defaults.lastFeedTopPeriod,
lastFeedScrollOffset:
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
defaults.lastFeedScrollOffset,
defaultRatingFilter: json['defaultRatingFilter'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?,
lastFeedRating: json['lastFeedRating'] as String?,
); );
} }
+102
View File
@@ -0,0 +1,102 @@
import '../models/post.dart';
enum MediaQualityMode {
auto,
dataSaver,
highQuality;
static MediaQualityMode fromName(String value) {
return MediaQualityMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => MediaQualityMode.auto,
);
}
String get label {
return switch (this) {
MediaQualityMode.auto => 'Auto',
MediaQualityMode.dataSaver => 'Data saver',
MediaQualityMode.highQuality => 'High quality',
};
}
}
class MediaUrlSelector {
const MediaUrlSelector._();
static List<String> feed(Post post) {
return _compact([post.previewUrl, post.sampleUrl]);
}
static List<String> preview(Post post) {
return _compact([post.sampleUrl, post.previewUrl, post.fileUrl]);
}
static List<String> details(
Post post, {
MediaQualityMode mode = MediaQualityMode.auto,
}) {
if (_isVideo(post)) {
return _compact([post.fileUrl, post.sampleUrl, post.previewUrl]);
}
if (_isGif(post)) {
return switch (mode) {
MediaQualityMode.dataSaver =>
_compact([post.sampleUrl, post.previewUrl, post.fileUrl]),
_ => _compact([post.fileUrl, post.sampleUrl, post.previewUrl]),
};
}
return switch (mode) {
MediaQualityMode.dataSaver =>
_compact([post.sampleUrl, post.previewUrl, post.fileUrl]),
MediaQualityMode.highQuality =>
_compact([post.fileUrl, post.sampleUrl, post.previewUrl]),
MediaQualityMode.auto =>
_compact([post.sampleUrl, post.fileUrl, post.previewUrl]),
};
}
static String? download(Post post) {
return _compact([post.fileUrl, post.sampleUrl, post.previewUrl])
.firstOrNull;
}
static List<String> video(Post post) {
return _compact([
post.fileUrl,
if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl,
if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl,
]);
}
static bool isVideo(Post post) => _isVideo(post);
static bool isGif(Post post) => _isGif(post);
static List<String> _compact(Iterable<String> urls) {
final seen = <String>{};
return urls
.map((url) => url.trim())
.where((url) => url.isNotEmpty && seen.add(url))
.toList(growable: false);
}
static bool _isVideo(Post post) {
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('video') ||
value.contains('.webm') ||
value.contains('.mp4') ||
value.contains('.mov');
}
static bool _isGif(Post post) {
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('gif') || value.contains('.gif');
}
static bool _looksLikeVideoUrl(String url) {
final value = url.toLowerCase();
return value.contains('.webm') ||
value.contains('.mp4') ||
value.contains('.mov');
}
}
@@ -15,13 +15,14 @@ class FeedController extends AsyncNotifier<FeedState> {
final providerIds = providers.map((provider) => provider.id).toSet(); final providerIds = providers.map((provider) => provider.id).toSet();
final initial = FeedState( final initial = FeedState(
providers: providers, providers: providers,
ratingFilter: settings.defaultRatingFilter,
topPeriodFilter: TopPeriodFilter.values.firstWhere( topPeriodFilter: TopPeriodFilter.values.firstWhere(
(value) => value.name == settings.defaultTopPeriodFilter, (value) => value.name == settings.lastFeedTopPeriod,
orElse: () => TopPeriodFilter.none, orElse: () => TopPeriodFilter.none,
), ),
selectedTags: settings.lastFeedTags,
selectedProviderIds: selectedProviderIds:
settings.selectedFeedProviderIds.where(providerIds.contains).toList(), settings.lastFeedProviderIds.where(providerIds.contains).toList(),
ratingFilter: settings.lastFeedRating ?? settings.defaultRatingFilter,
); );
state = AsyncData(initial); state = AsyncData(initial);
await loadInitial(); await loadInitial();
@@ -66,6 +67,7 @@ class FeedController extends AsyncNotifier<FeedState> {
state = AsyncData( state = AsyncData(
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []),
); );
await saveSession(scrollOffset: 0);
await ref.read(searchServiceProvider).saveSearch(query, 0); await ref.read(searchServiceProvider).saveSearch(query, 0);
await refresh(); await refresh();
final count = state.value?.posts.length ?? 0; final count = state.value?.posts.length ?? 0;
@@ -99,7 +101,11 @@ class FeedController extends AsyncNotifier<FeedState> {
current.copyWith(selectedProviderIds: providerIds, posts: [])); current.copyWith(selectedProviderIds: providerIds, posts: []));
final settings = await _settings(); final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings( await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(selectedFeedProviderIds: providerIds)); settings.copyWith(
selectedFeedProviderIds: providerIds,
lastFeedProviderIds: providerIds,
),
);
await refresh(); await refresh();
} }
@@ -110,6 +116,7 @@ class FeedController extends AsyncNotifier<FeedState> {
await ref.read(settingsServiceProvider).updateSettings( await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(defaultTopPeriodFilter: period.name), settings.copyWith(defaultTopPeriodFilter: period.name),
); );
await saveSession(scrollOffset: 0);
await refresh(); await refresh();
} }
@@ -120,6 +127,7 @@ class FeedController extends AsyncNotifier<FeedState> {
ratingFilter: rating, clearRating: rating == null, posts: []), ratingFilter: rating, clearRating: rating == null, posts: []),
); );
await refresh(); await refresh();
await saveSession(scrollOffset: 0);
} }
Future<void> clearFilters() async { Future<void> clearFilters() async {
@@ -134,12 +142,32 @@ class FeedController extends AsyncNotifier<FeedState> {
), ),
); );
final settings = await _settings(); final settings = await _settings();
await ref await ref.read(settingsServiceProvider).updateSettings(settings.copyWith(
.read(settingsServiceProvider) selectedFeedProviderIds: [],
.updateSettings(settings.copyWith(selectedFeedProviderIds: [])); lastFeedTags: [],
lastFeedProviderIds: [],
clearLastFeedRating: true,
lastFeedTopPeriod: TopPeriodFilter.none.name,
lastFeedScrollOffset: 0,
));
await refresh(); await refresh();
} }
Future<void> saveSession({double? scrollOffset}) async {
final current = state.value;
if (current == null) return;
final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(
lastFeedTags: current.selectedTags,
lastFeedProviderIds: current.selectedProviderIds,
lastFeedRating: current.ratingFilter,
lastFeedTopPeriod: current.topPeriodFilter.name,
lastFeedScrollOffset: scrollOffset ?? settings.lastFeedScrollOffset,
),
);
}
Future<List<Post>> _load({ Future<List<Post>> _load({
required bool refresh, required bool refresh,
required FeedState current, required FeedState current,
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../app/app.dart'; import '../../../app/app.dart';
import '../../../app/responsive.dart'; import '../../../app/responsive.dart';
@@ -28,7 +30,10 @@ class FeedScreen extends ConsumerStatefulWidget {
class _FeedScreenState extends ConsumerState<FeedScreen> { class _FeedScreenState extends ConsumerState<FeedScreen> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final Set<String> _selectedKeys = {};
bool _selectionMode = false;
bool _usedInitialQuery = false; bool _usedInitialQuery = false;
Timer? _scrollSaveDebounce;
@override @override
void initState() { void initState() {
@@ -37,11 +42,30 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
if (_scrollController.position.extentAfter < 800) { if (_scrollController.position.extentAfter < 800) {
ref.read(feedControllerProvider.notifier).loadNextPage(); ref.read(feedControllerProvider.notifier).loadNextPage();
} }
_scrollSaveDebounce?.cancel();
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
ref
.read(feedControllerProvider.notifier)
.saveSession(scrollOffset: _scrollController.offset);
});
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
_scrollController.jumpTo(
settings.lastFeedScrollOffset.clamp(
0.0,
_scrollController.position.maxScrollExtent,
),
);
}
}); });
} }
@override @override
void dispose() { void dispose() {
_scrollSaveDebounce?.cancel();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -64,6 +88,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
return Shortcuts( return Shortcuts(
shortcuts: { shortcuts: {
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(), LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
const _SelectVisibleIntent(),
}, },
child: Actions( child: Actions(
actions: { actions: {
@@ -73,6 +101,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
return null; return null;
}, },
), ),
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
onInvoke: (_) {
_clearSelection();
return null;
},
),
_SelectVisibleIntent: CallbackAction<_SelectVisibleIntent>(
onInvoke: (_) {
final state = feed.value;
if (state != null) {
setState(() {
_selectionMode = true;
_selectedKeys
..clear()
..addAll(state.posts.map((post) => post.cacheKey));
});
}
return null;
},
),
}, },
child: AdaptiveScaffold( child: AdaptiveScaffold(
title: 'Feed', title: 'Feed',
@@ -106,6 +154,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
ref.read(feedControllerProvider.notifier).refresh(), ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: () => onClearFilters: () =>
ref.read(feedControllerProvider.notifier).clearFilters(), ref.read(feedControllerProvider.notifier).clearFilters(),
selectionMode: _selectionMode,
onToggleSelectionMode: () {
setState(() {
_selectionMode = !_selectionMode;
if (!_selectionMode) _selectedKeys.clear();
});
},
onQuickProviderToggle: (providerId) { onQuickProviderToggle: (providerId) {
if (providerId == '__all__') { if (providerId == '__all__') {
ref ref
@@ -167,10 +222,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
loading: state.isLoadingMore, loading: state.isLoadingMore,
favoriteKeys: favoriteKeys, favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys, viewedKeys: viewedKeys,
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
onOpen: (post) => context.push( onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}', '/post/${post.providerId}/${post.id}',
extra: post, extra: post,
), ),
onPreview: (post) => _showPreview(context, post),
onToggleSelected: (post) => _toggleSelected(post),
onFavorite: (post) => onFavorite: (post) =>
_toggleFavorite(ref, post, favoriteKeys), _toggleFavorite(ref, post, favoriteKeys),
onAddToCollection: (post) => onAddToCollection: (post) =>
@@ -178,6 +237,21 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
), ),
), ),
), ),
if (_selectionMode && _selectedKeys.isNotEmpty)
_BatchActionBar(
count: _selectedKeys.length,
onFavorite: () => _favoriteSelected(
ref,
state.posts,
favoriteKeys,
),
onCollection: () => _addSelectedToCollection(
context,
ref,
state.posts,
),
onClear: _clearSelection,
),
], ],
), ),
), ),
@@ -186,6 +260,95 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
); );
} }
void _toggleSelected(Post post) {
setState(() {
_selectionMode = true;
if (!_selectedKeys.add(post.cacheKey)) {
_selectedKeys.remove(post.cacheKey);
}
if (_selectedKeys.isEmpty) _selectionMode = false;
});
}
void _clearSelection() {
if (!_selectionMode && _selectedKeys.isEmpty) return;
setState(() {
_selectionMode = false;
_selectedKeys.clear();
});
}
List<Post> _selectedPosts(List<Post> posts) {
return posts
.where((post) => _selectedKeys.contains(post.cacheKey))
.toList();
}
Future<void> _favoriteSelected(
WidgetRef ref,
List<Post> posts,
Set<String> favoriteKeys,
) async {
for (final post in _selectedPosts(posts)) {
if (!favoriteKeys.contains(post.cacheKey)) {
await ref.read(favoriteServiceProvider).addFavorite(post);
}
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
_clearSelection();
}
Future<void> _addSelectedToCollection(
BuildContext context,
WidgetRef ref,
List<Post> posts,
) async {
final selectedPosts = _selectedPosts(posts);
final result = await ref.read(collectionServiceProvider).getCollections();
final collections =
result is Success<List<Collection>> ? result.data : <Collection>[];
if (!context.mounted) return;
await showAddToCollectionPicker(
context,
collections: collections,
onSelected: (collection) async {
await ref
.read(collectionServiceProvider)
.addPostsToCollection(collection.id, selectedPosts);
_clearSelection();
},
onCreate: () => showCollectionFormDialog(context, ref),
);
}
Future<void> _showPreview(BuildContext context, Post post) {
final imageUrl = MediaUrlSelector.preview(post).firstOrNull;
final child = imageUrl == null
? const Center(child: Icon(Icons.broken_image_rounded, size: 48))
: CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.contain);
if (Responsive.isMobile(context)) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.72,
child: Padding(padding: const EdgeInsets.all(8), child: child),
),
);
}
return showDialog<void>(
context: context,
builder: (context) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760),
child: Padding(padding: const EdgeInsets.all(8), child: child),
),
),
);
}
Future<void> _addToCollection( Future<void> _addToCollection(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -227,3 +390,70 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
class _RefreshIntent extends Intent { class _RefreshIntent extends Intent {
const _RefreshIntent(); const _RefreshIntent();
} }
class _ClearSelectionIntent extends Intent {
const _ClearSelectionIntent();
}
class _SelectVisibleIntent extends Intent {
const _SelectVisibleIntent();
}
class _BatchActionBar extends StatelessWidget {
const _BatchActionBar({
required this.count,
required this.onFavorite,
required this.onCollection,
required this.onClear,
});
final int count;
final VoidCallback onFavorite;
final VoidCallback onCollection;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 10),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 16),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Text('$count selected',
style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
IconButton.filledTonal(
tooltip: 'Favorite selected',
onPressed: onFavorite,
icon: const Icon(Icons.favorite_rounded),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
onPressed: onCollection,
icon: const Icon(Icons.add_rounded),
label: const Text('Collection'),
),
IconButton(
tooltip: 'Clear',
onPressed: onClear,
icon: const Icon(Icons.close_rounded),
),
],
),
),
),
),
);
}
}
@@ -22,6 +22,8 @@ class FeedToolbar extends StatefulWidget {
required this.onSearchChanged, required this.onSearchChanged,
required this.onSuggestionTap, required this.onSuggestionTap,
required this.onTopPeriodChanged, required this.onTopPeriodChanged,
this.onToggleSelectionMode,
this.selectionMode = false,
super.key, super.key,
}); });
@@ -40,6 +42,8 @@ class FeedToolbar extends StatefulWidget {
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
final ValueChanged<String> onSuggestionTap; final ValueChanged<String> onSuggestionTap;
final ValueChanged<TopPeriodFilter> onTopPeriodChanged; final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
final VoidCallback? onToggleSelectionMode;
final bool selectionMode;
@override @override
State<FeedToolbar> createState() => _FeedToolbarState(); State<FeedToolbar> createState() => _FeedToolbarState();
@@ -91,6 +95,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded),
),
IconButton.filledTonal( IconButton.filledTonal(
tooltip: 'Providers', tooltip: 'Providers',
onPressed: widget.onProviderFilter, onPressed: widget.onProviderFilter,
@@ -184,9 +195,12 @@ class _FeedToolbarState extends State<FeedToolbar> {
primary: false, primary: false,
children: [ children: [
IconButton.filledTonal( IconButton.filledTonal(
tooltip: 'Providers', tooltip:
onPressed: widget.onProviderFilter, widget.selectionMode ? 'Exit select' : 'Select posts',
icon: const Icon(Icons.hub_rounded), onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
IconButton.filledTonal( IconButton.filledTonal(
@@ -84,6 +84,8 @@ class PostDetailsScreen extends ConsumerWidget {
final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1 final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
? feedPosts[currentIndex + 1] ? feedPosts[currentIndex + 1]
: null; : null;
final qualityMode =
MediaQualityMode.fromName(settings.mediaQualityMode);
if (Responsive.isMobile(context)) { if (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) { if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager( return _MobilePostPager(
@@ -95,6 +97,7 @@ class PostDetailsScreen extends ConsumerWidget {
post, post,
settings, settings,
favoriteKeys, favoriteKeys,
qualityMode,
), ),
); );
} }
@@ -104,6 +107,7 @@ class PostDetailsScreen extends ConsumerWidget {
post, post,
settings, settings,
favoriteKeys, favoriteKeys,
qualityMode,
); );
} }
return Shortcuts( return Shortcuts(
@@ -116,6 +120,7 @@ class PostDetailsScreen extends ConsumerWidget {
const _ToggleFavoriteIntent(), const _ToggleFavoriteIntent(),
LogicalKeySet(LogicalKeyboardKey.keyC): LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(), const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
}, },
child: Actions( child: Actions(
@@ -150,6 +155,14 @@ class PostDetailsScreen extends ConsumerWidget {
return null; return null;
}, },
), ),
_DownloadIntent: CallbackAction<_DownloadIntent>(
onInvoke: (_) {
if (settings.allowDownloads) {
_download(context, ref, post);
}
return null;
},
),
}, },
child: Focus( child: Focus(
autofocus: true, autofocus: true,
@@ -170,7 +183,10 @@ class PostDetailsScreen extends ConsumerWidget {
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760), constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(post: post), child: PostMediaViewer(
post: post,
qualityMode: qualityMode,
),
), ),
), ),
), ),
@@ -243,6 +259,7 @@ class PostDetailsScreen extends ConsumerWidget {
Post post, Post post,
AppSettings settings, AppSettings settings,
Set<String> favoriteKeys, Set<String> favoriteKeys,
MediaQualityMode qualityMode,
) { ) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
@@ -251,7 +268,12 @@ class PostDetailsScreen extends ConsumerWidget {
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62, maxHeight: MediaQuery.sizeOf(context).height * 0.62,
), ),
child: Center(child: PostMediaViewer(post: post)), child: Center(
child: PostMediaViewer(
post: post,
qualityMode: qualityMode,
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
PostActionBar( PostActionBar(
@@ -320,22 +342,12 @@ class PostDetailsScreen extends ConsumerWidget {
Future<void> _download(BuildContext context, WidgetRef ref, Post post) async { Future<void> _download(BuildContext context, WidgetRef ref, Post post) async {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( await ref.read(downloadManagerServiceProvider).start(post);
const SnackBar(content: Text('Downloading...')),
);
try {
final saved = await ref.read(downloadServiceProvider).downloadPost(post);
if (!context.mounted || saved == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download complete: $saved')),
);
} catch (error) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download failed: $error')), const SnackBar(content: Text('Download started')),
); );
} }
}
Future<void> _toggleFavorite( Future<void> _toggleFavorite(
WidgetRef ref, WidgetRef ref,
@@ -583,6 +595,10 @@ class _CloseIntent extends Intent {
const _CloseIntent(); const _CloseIntent();
} }
class _DownloadIntent extends Intent {
const _DownloadIntent();
}
class _CommentsSection extends ConsumerStatefulWidget { class _CommentsSection extends ConsumerStatefulWidget {
const _CommentsSection({required this.post}); const _CommentsSection({required this.post});
@@ -24,6 +24,7 @@ class PostMediaViewer extends StatefulWidget {
this.initialMuted = false, this.initialMuted = false,
this.initialCoverVideo = false, this.initialCoverVideo = false,
this.initialHalfVolume = false, this.initialHalfVolume = false,
this.qualityMode = MediaQualityMode.auto,
this.onPlaybackSnapshot, this.onPlaybackSnapshot,
super.key, super.key,
}); });
@@ -36,6 +37,7 @@ class PostMediaViewer extends StatefulWidget {
final bool initialMuted; final bool initialMuted;
final bool initialCoverVideo; final bool initialCoverVideo;
final bool initialHalfVolume; final bool initialHalfVolume;
final MediaQualityMode qualityMode;
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot; final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
@override @override
@@ -281,18 +283,11 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
} }
List<String> _buildImageUrls(Post post) { List<String> _buildImageUrls(Post post) {
final urls = post.fileType.toLowerCase().contains('gif') return MediaUrlSelector.details(post, mode: widget.qualityMode);
? [post.fileUrl, post.sampleUrl, post.previewUrl]
: [post.sampleUrl, post.fileUrl, post.previewUrl];
return urls.where((url) => url.trim().isNotEmpty).toSet().toList();
} }
List<String> _buildVideoUrls(Post post) { List<String> _buildVideoUrls(Post post) {
return { return MediaUrlSelector.video(post);
post.fileUrl,
if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl,
if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl,
}.where((url) => url.trim().isNotEmpty).toList();
} }
bool _isVideo(Post post) { bool _isVideo(Post post) {
@@ -318,13 +313,6 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
return post.fileUrl.trim(); return post.fileUrl.trim();
} }
bool _looksLikeVideoUrl(String url) {
final value = url.toLowerCase();
return value.contains('.webm') ||
value.contains('.mp4') ||
value.contains('.mov');
}
Map<String, String> _headersFor(Post post) { Map<String, String> _headersFor(Post post) {
return { return {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser', 'User-Agent': 'RuleGel/0.2 Flutter local booru browser',
@@ -796,6 +784,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
initialMuted: widget.muted, initialMuted: widget.muted,
initialHalfVolume: widget.halfVolume, initialHalfVolume: widget.halfVolume,
initialCoverVideo: widget.coverVideo, initialCoverVideo: widget.coverVideo,
qualityMode: MediaQualityMode.highQuality,
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot, onPlaybackSnapshot: (snapshot) => _snapshot = snapshot,
), ),
), ),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../backend/backend.dart'; import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -57,6 +58,30 @@ class SettingsScreen extends ConsumerWidget {
onChanged: (value) => onChanged: (value) =>
_update(ref, settings.copyWith(allowDownloads: value)), _update(ref, settings.copyWith(allowDownloads: value)),
), ),
DropdownButtonFormField<String>(
initialValue: settings.mediaQualityMode,
decoration: const InputDecoration(labelText: 'Media quality'),
items: [
for (final mode in MediaQualityMode.values)
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
],
onChanged: (value) => _update(
ref,
settings.copyWith(mediaQualityMode: value ?? 'auto'),
),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () => context.go('/providers'),
icon: const Icon(Icons.hub_rounded),
label: const Text('Providers'),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () => context.go('/providers/check'),
icon: const Icon(Icons.network_check_rounded),
label: const Text('Provider diagnostics'),
),
SwitchListTile( SwitchListTile(
value: settings.hideViewedPosts, value: settings.hideViewedPosts,
title: const Text('Hide viewed posts'), title: const Text('Hide viewed posts'),
+13
View File
@@ -27,6 +27,19 @@ class AppShell extends StatelessWidget {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'), const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.digit6):
const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.digit7):
const _NavigateIntent('/settings'),
}, },
child: Actions( child: Actions(
actions: { actions: {
+49 -1
View File
@@ -16,6 +16,10 @@ class PostCard extends StatefulWidget {
required this.onOpen, required this.onOpen,
required this.onFavorite, required this.onFavorite,
this.onAddToCollection, this.onAddToCollection,
this.onPreview,
this.onToggleSelected,
this.selectionMode = false,
this.selected = false,
super.key, super.key,
}); });
@@ -27,6 +31,10 @@ class PostCard extends StatefulWidget {
final VoidCallback onOpen; final VoidCallback onOpen;
final VoidCallback onFavorite; final VoidCallback onFavorite;
final VoidCallback? onAddToCollection; final VoidCallback? onAddToCollection;
final VoidCallback? onPreview;
final VoidCallback? onToggleSelected;
final bool selectionMode;
final bool selected;
@override @override
State<PostCard> createState() => _PostCardState(); State<PostCard> createState() => _PostCardState();
@@ -46,7 +54,24 @@ class _PostCardState extends State<PostCard> {
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onOpen, onTap: () {
if (widget.selectionMode) {
widget.onToggleSelected?.call();
return;
}
widget.onOpen();
},
onLongPress: () {
if (widget.selectionMode && widget.onToggleSelected != null) {
widget.onToggleSelected!();
return;
}
if (widget.onPreview != null) {
widget.onPreview!();
return;
}
widget.onToggleSelected?.call();
},
onSecondaryTapDown: (details) { onSecondaryTapDown: (details) {
showMenu<void>( showMenu<void>(
context: context, context: context,
@@ -119,6 +144,29 @@ class _PostCardState extends State<PostCard> {
child: _SeenBadge(), child: _SeenBadge(),
), ),
], ],
if (widget.selected)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.24),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
child: const Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.check_circle_rounded),
),
),
),
),
if (mobile) if (mobile)
Positioned( Positioned(
right: 8, right: 8,
+13
View File
@@ -16,6 +16,10 @@ class PostMasonryGrid extends StatelessWidget {
required this.onOpen, required this.onOpen,
required this.onFavorite, required this.onFavorite,
this.onAddToCollection, this.onAddToCollection,
this.onPreview,
this.onToggleSelected,
this.selectionMode = false,
this.selectedKeys = const {},
this.favoriteKeys = const {}, this.favoriteKeys = const {},
this.viewedKeys = const {}, this.viewedKeys = const {},
this.loading = false, this.loading = false,
@@ -31,6 +35,10 @@ class PostMasonryGrid extends StatelessWidget {
final ValueChanged<Post> onOpen; final ValueChanged<Post> onOpen;
final ValueChanged<Post> onFavorite; final ValueChanged<Post> onFavorite;
final ValueChanged<Post>? onAddToCollection; final ValueChanged<Post>? onAddToCollection;
final ValueChanged<Post>? onPreview;
final ValueChanged<Post>? onToggleSelected;
final bool selectionMode;
final Set<String> selectedKeys;
final Set<String> favoriteKeys; final Set<String> favoriteKeys;
final Set<String> viewedKeys; final Set<String> viewedKeys;
final bool loading; final bool loading;
@@ -57,6 +65,11 @@ class PostMasonryGrid extends StatelessWidget {
isViewed: viewedKeys.contains(post.cacheKey), isViewed: viewedKeys.contains(post.cacheKey),
onOpen: () => onOpen(post), onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post), onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection: onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post), onAddToCollection == null ? null : () => onAddToCollection!(post),
); );