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
+3
View File
@@ -1,6 +1,7 @@
export 'di/backend_providers.dart';
export 'models/collection.dart';
export 'models/content_provider_config.dart';
export 'models/download_task.dart';
export 'models/favorite.dart';
export 'models/post.dart';
export 'models/post_comment.dart';
@@ -14,6 +15,7 @@ export 'providers/content_provider.dart';
export 'providers/provider_manager.dart';
export 'services/collection_service.dart';
export 'services/download_service.dart';
export 'services/download_manager_service.dart';
export 'services/favorite_service.dart';
export 'services/feed_service.dart';
export 'services/provider_check_service.dart';
@@ -21,3 +23,4 @@ export 'services/search_service.dart';
export 'services/settings_service.dart';
export 'services/viewed_history_service.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 '../providers/provider_factory.dart';
import '../providers/provider_manager.dart';
import '../models/download_task.dart';
import '../repositories/collection_repository.dart';
import '../repositories/favorite_repository.dart';
import '../repositories/post_repository.dart';
@@ -17,6 +18,7 @@ import '../repositories/search_repository.dart';
import '../repositories/viewed_post_repository.dart';
import '../services/collection_service.dart';
import '../services/download_service.dart';
import '../services/download_manager_service.dart';
import '../services/favorite_service.dart';
import '../services/feed_service.dart';
import '../services/provider_check_service.dart';
@@ -147,3 +149,12 @@ final settingsServiceProvider = Provider<SettingsService>((ref) {
final downloadServiceProvider = Provider<DownloadService>((ref) {
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) {
final now = DateTime.now();
return switch (period) {
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'],
};
}
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) {
final now = DateTime.now();
return switch (period) {
TopPeriodFilter.none => const [],
TopPeriodFilter.month => const ['order:rank'],
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'],
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'],
};
}
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) {
if (value is int) return value;
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) {
return switch (period) {
TopPeriodFilter.none => const [],
TopPeriodFilter.month ||
TopPeriodFilter.year ||
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
TopPeriodFilter.month || TopPeriodFilter.year => const [],
TopPeriodFilter.allTime => const ['sort:score:desc'],
};
}
+2 -4
View File
@@ -166,10 +166,8 @@ class MoebooruProvider
List<String> _topTags(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => const [],
TopPeriodFilter.month ||
TopPeriodFilter.year ||
TopPeriodFilter.allTime =>
const ['order:score'],
TopPeriodFilter.month || TopPeriodFilter.year => const [],
TopPeriodFilter.allTime => const ['order:score'],
};
}
+2 -4
View File
@@ -22,10 +22,8 @@ class Rule34Provider extends GelbooruProvider {
}) async {
final topTags = switch (topPeriod) {
TopPeriodFilter.none => const <String>[],
TopPeriodFilter.month ||
TopPeriodFilter.year ||
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.allTime => const ['sort:score:desc'],
};
final response = await dio.get<dynamic>(
'/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(
String collectionId,
String postId,
@@ -56,6 +56,13 @@ class CollectionService {
return _repository.addPost(collectionId, post);
}
Future<Result<void>> addPostsToCollection(
String collectionId,
List<Post> posts,
) {
return _repository.addPosts(collectionId, posts);
}
Future<Result<void>> removePostFromCollection(
String collectionId,
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 '../models/post.dart';
import '../utils/media_quality.dart';
class DownloadService {
DownloadService({Dio? dio}) : _dio = dio ?? Dio();
@@ -43,10 +44,7 @@ class DownloadService {
}
String? _downloadUrl(Post post) {
for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) {
if (url.trim().isNotEmpty) return url;
}
return null;
return MediaUrlSelector.download(post);
}
String _fileName(Post post, String url) {
+20 -1
View File
@@ -78,7 +78,7 @@ class FeedService {
final viewedKeys = viewedKeysResult is Success<Set<String>>
? viewedKeysResult.data
: <String>{};
final filteredPosts = posts
final filteredPosts = _applyTopPeriod(posts, topPeriod)
.where((post) => postMatchesRequestedTags(post, tags))
.where((post) => postPassesTagFilters(post, settings))
.where((post) =>
@@ -90,6 +90,25 @@ class FeedService {
);
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) {
@@ -28,7 +28,13 @@ class AppSettings {
required this.whitelistedTags,
required this.smartBlacklistRules,
required this.hideViewedPosts,
required this.mediaQualityMode,
required this.lastFeedTags,
required this.lastFeedProviderIds,
required this.lastFeedTopPeriod,
required this.lastFeedScrollOffset,
this.defaultRatingFilter,
this.lastFeedRating,
});
final List<String> enabledProviderIds;
@@ -48,7 +54,13 @@ class AppSettings {
final List<String> whitelistedTags;
final List<String> smartBlacklistRules;
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? lastFeedRating;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
@@ -68,7 +80,13 @@ class AppSettings {
whitelistedTags: [],
smartBlacklistRules: [],
hideViewedPosts: false,
mediaQualityMode: 'auto',
lastFeedTags: [],
lastFeedProviderIds: [],
lastFeedTopPeriod: 'none',
lastFeedScrollOffset: 0,
defaultRatingFilter: null,
lastFeedRating: null,
);
AppSettings copyWith({
@@ -89,7 +107,14 @@ class AppSettings {
List<String>? whitelistedTags,
List<String>? smartBlacklistRules,
bool? hideViewedPosts,
String? mediaQualityMode,
List<String>? lastFeedTags,
List<String>? lastFeedProviderIds,
String? lastFeedTopPeriod,
double? lastFeedScrollOffset,
String? defaultRatingFilter,
String? lastFeedRating,
bool clearLastFeedRating = false,
}) {
return AppSettings(
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
@@ -111,7 +136,14 @@ class AppSettings {
whitelistedTags: whitelistedTags ?? this.whitelistedTags,
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
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,
lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
);
}
@@ -133,7 +165,13 @@ class AppSettings {
'whitelistedTags': whitelistedTags,
'smartBlacklistRules': smartBlacklistRules,
'hideViewedPosts': hideViewedPosts,
'mediaQualityMode': mediaQualityMode,
'lastFeedTags': lastFeedTags,
'lastFeedProviderIds': lastFeedProviderIds,
'lastFeedTopPeriod': lastFeedTopPeriod,
'lastFeedScrollOffset': lastFeedScrollOffset,
'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -178,7 +216,24 @@ class AppSettings {
),
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?,
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');
}
}