feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -16,9 +16,12 @@ class DownloadManagerService {
|
||||
final DownloadedMediaService? _downloadedMediaService;
|
||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||
final Map<String, DownloadTask> _tasks = {};
|
||||
final Set<String> _runningTaskIds = {};
|
||||
bool _queuePaused = false;
|
||||
|
||||
Stream<List<DownloadTask>> get stream => _controller.stream;
|
||||
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
|
||||
bool get queuePaused => _queuePaused;
|
||||
|
||||
Future<DownloadTask> start(Post post) async {
|
||||
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
|
||||
@@ -30,7 +33,7 @@ class DownloadManagerService {
|
||||
status: DownloadTaskStatus.queued,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
_pumpQueue();
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -49,10 +52,21 @@ class DownloadManagerService {
|
||||
openAfterDownload: openAfterDownload,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
_pumpQueue();
|
||||
return task;
|
||||
}
|
||||
|
||||
void pauseQueue() {
|
||||
_queuePaused = true;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void resumeQueue() {
|
||||
_queuePaused = false;
|
||||
_emit();
|
||||
_pumpQueue();
|
||||
}
|
||||
|
||||
Future<void> retry(String taskId) async {
|
||||
final existing = _tasks[taskId];
|
||||
if (existing == null) return;
|
||||
@@ -62,7 +76,7 @@ class DownloadManagerService {
|
||||
error: null,
|
||||
);
|
||||
_set(task);
|
||||
await _run(task);
|
||||
_pumpQueue();
|
||||
}
|
||||
|
||||
void cancel(String taskId) {
|
||||
@@ -71,8 +85,19 @@ class DownloadManagerService {
|
||||
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
|
||||
}
|
||||
|
||||
void clearFinished() {
|
||||
_tasks.removeWhere(
|
||||
(_, task) =>
|
||||
task.status == DownloadTaskStatus.completed ||
|
||||
task.status == DownloadTaskStatus.failed ||
|
||||
task.status == DownloadTaskStatus.canceled,
|
||||
);
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<void> _run(DownloadTask task) async {
|
||||
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
|
||||
if (!_runningTaskIds.add(task.id)) return;
|
||||
_set(task.copyWith(status: DownloadTaskStatus.running));
|
||||
try {
|
||||
final saved = task.sourceUrl == null
|
||||
@@ -121,14 +146,30 @@ class DownloadManagerService {
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_runningTaskIds.remove(task.id);
|
||||
_pumpQueue();
|
||||
}
|
||||
}
|
||||
|
||||
void _set(DownloadTask task) {
|
||||
_tasks[task.id] = task;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
_controller.add(tasks);
|
||||
}
|
||||
|
||||
void _pumpQueue() {
|
||||
if (_queuePaused) return;
|
||||
for (final task in tasks) {
|
||||
if (task.status != DownloadTaskStatus.queued) continue;
|
||||
if (_runningTaskIds.contains(task.id)) continue;
|
||||
unawaited(_run(task));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgress(String taskId, int received, int total) {
|
||||
if (total <= 0) return;
|
||||
final current = _tasks[taskId];
|
||||
@@ -143,7 +184,7 @@ class DownloadManagerService {
|
||||
final current = _tasks[taskId];
|
||||
if (current?.status != DownloadTaskStatus.completed) return;
|
||||
_tasks.remove(taskId);
|
||||
_controller.add(tasks);
|
||||
_emit();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ class DownloadService {
|
||||
if (Platform.isAndroid) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(tempDir.path, fileName);
|
||||
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
|
||||
await _dio.download(
|
||||
url,
|
||||
tempPath,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(headers: _headersFor(post)),
|
||||
);
|
||||
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
|
||||
'path': tempPath,
|
||||
'fileName': fileName,
|
||||
@@ -39,7 +44,12 @@ class DownloadService {
|
||||
|
||||
final location = await getSaveLocation(suggestedName: fileName);
|
||||
if (location == null) return null;
|
||||
await _dio.download(url, location.path, onReceiveProgress: onProgress);
|
||||
await _dio.download(
|
||||
url,
|
||||
location.path,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(headers: _headersFor(post)),
|
||||
);
|
||||
return location.path;
|
||||
}
|
||||
|
||||
@@ -120,4 +130,26 @@ class DownloadService {
|
||||
if (value.contains('.png') || value.contains('png')) return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
final lower = '${post.providerId} ${post.providerName} '
|
||||
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
|
||||
.toLowerCase();
|
||||
return {
|
||||
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
|
||||
: 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
|
||||
: '*/*',
|
||||
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
|
||||
if (lower.contains('rule34') && !lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
if (lower.contains('realbooru'))
|
||||
'Referer':
|
||||
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
|
||||
if (lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
||||
.toList(growable: false);
|
||||
if (requested.isEmpty) return true;
|
||||
final postTags = _postTagSet(post);
|
||||
return requested.every(postTags.contains);
|
||||
return requested.every(
|
||||
(requestedTag) => postTags.any(
|
||||
(postTag) =>
|
||||
postTag == requestedTag ||
|
||||
postTag.startsWith('${requestedTag}_') ||
|
||||
postTag.startsWith('$requestedTag-'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool postPassesTagFilters(Post post, AppSettings settings) {
|
||||
|
||||
@@ -32,7 +32,7 @@ class SearchService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
|
||||
return _repository.recent(limit: limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class AppSettings {
|
||||
required this.videoPlayerHalfVolume,
|
||||
required this.videoPlayerLoop,
|
||||
required this.videoPlayerCover,
|
||||
required this.videoPlaybackPositions,
|
||||
required this.hiddenPostKeys,
|
||||
required this.diagnosticLogLines,
|
||||
required this.lastFeedTags,
|
||||
@@ -82,6 +83,7 @@ class AppSettings {
|
||||
final bool videoPlayerHalfVolume;
|
||||
final bool videoPlayerLoop;
|
||||
final bool videoPlayerCover;
|
||||
final Map<String, int> videoPlaybackPositions;
|
||||
final List<String> hiddenPostKeys;
|
||||
final List<String> diagnosticLogLines;
|
||||
final List<String> lastFeedTags;
|
||||
@@ -108,7 +110,7 @@ class AppSettings {
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
autoDownloadFavorites: false,
|
||||
autoDownloadFavorites: true,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
@@ -123,6 +125,7 @@ class AppSettings {
|
||||
videoPlayerHalfVolume: false,
|
||||
videoPlayerLoop: false,
|
||||
videoPlayerCover: false,
|
||||
videoPlaybackPositions: {},
|
||||
hiddenPostKeys: [],
|
||||
diagnosticLogLines: [],
|
||||
lastFeedTags: [],
|
||||
@@ -165,6 +168,7 @@ class AppSettings {
|
||||
bool? videoPlayerHalfVolume,
|
||||
bool? videoPlayerLoop,
|
||||
bool? videoPlayerCover,
|
||||
Map<String, int>? videoPlaybackPositions,
|
||||
List<String>? hiddenPostKeys,
|
||||
List<String>? diagnosticLogLines,
|
||||
List<String>? lastFeedTags,
|
||||
@@ -212,6 +216,8 @@ class AppSettings {
|
||||
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
|
||||
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
|
||||
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
|
||||
videoPlaybackPositions:
|
||||
videoPlaybackPositions ?? this.videoPlaybackPositions,
|
||||
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
|
||||
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
|
||||
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
|
||||
@@ -256,6 +262,7 @@ class AppSettings {
|
||||
'videoPlayerHalfVolume': videoPlayerHalfVolume,
|
||||
'videoPlayerLoop': videoPlayerLoop,
|
||||
'videoPlayerCover': videoPlayerCover,
|
||||
'videoPlaybackPositions': videoPlaybackPositions,
|
||||
'hiddenPostKeys': hiddenPostKeys,
|
||||
'diagnosticLogLines': diagnosticLogLines,
|
||||
'lastFeedTags': lastFeedTags,
|
||||
@@ -334,6 +341,10 @@ class AppSettings {
|
||||
(json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop,
|
||||
videoPlayerCover:
|
||||
(json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover,
|
||||
videoPlaybackPositions: Map<String, int>.from(
|
||||
(json['videoPlaybackPositions'] as Map?) ??
|
||||
defaults.videoPlaybackPositions,
|
||||
),
|
||||
hiddenPostKeys: List<String>.from(
|
||||
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
|
||||
),
|
||||
@@ -469,6 +480,30 @@ class SettingsService {
|
||||
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveVideoPlaybackPosition(
|
||||
String cacheKey,
|
||||
int milliseconds, {
|
||||
int maxEntries = 300,
|
||||
}) async {
|
||||
if (cacheKey.isEmpty || milliseconds < 1000) return const Success(null);
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
final existing = settings.videoPlaybackPositions[cacheKey] ?? 0;
|
||||
if ((existing - milliseconds).abs() < 1000) return const Success(null);
|
||||
final positions = <String, int>{
|
||||
...settings.videoPlaybackPositions,
|
||||
cacheKey: milliseconds,
|
||||
};
|
||||
if (positions.length > maxEntries) {
|
||||
final removeCount = positions.length - maxEntries;
|
||||
for (final key in positions.keys.take(removeCount).toList()) {
|
||||
positions.remove(key);
|
||||
}
|
||||
}
|
||||
return updateSettings(settings.copyWith(videoPlaybackPositions: positions));
|
||||
}
|
||||
|
||||
Future<Result<void>> appendDiagnosticLog(String message) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
|
||||
@@ -35,7 +35,38 @@ class ViewedHistoryService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<ViewedPostEntry>>> getViewedPostEntries() async {
|
||||
final viewedResult = await _repository.recent();
|
||||
if (viewedResult is Error<List<ViewedPost>>) {
|
||||
return Error(viewedResult.failure);
|
||||
}
|
||||
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
|
||||
final postsResult = await _postRepository.getCachedPostsByKeys(
|
||||
viewed.map((item) => item.viewedKey),
|
||||
);
|
||||
if (postsResult is Error<List<Post>>) {
|
||||
return Error(postsResult.failure);
|
||||
}
|
||||
final postsByKey = {
|
||||
for (final post in (postsResult as Success<List<Post>>).data)
|
||||
post.cacheKey: post,
|
||||
};
|
||||
return Success([
|
||||
for (final item in viewed)
|
||||
if (postsByKey[item.viewedKey] != null)
|
||||
ViewedPostEntry(
|
||||
post: postsByKey[item.viewedKey]!, viewedAt: item.viewedAt),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() {
|
||||
return _repository.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class ViewedPostEntry {
|
||||
const ViewedPostEntry({required this.post, required this.viewedAt});
|
||||
|
||||
final Post post;
|
||||
final DateTime viewedAt;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user