Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e419c7ad78 | ||
|
|
cc5a1a4f82 | ||
|
|
ec6d8156e0 | ||
|
|
45faa12133 | ||
|
|
7cccb2cd8d | ||
|
|
26cc1b321b |
@@ -4,8 +4,11 @@ build/
|
||||
dist/
|
||||
/isar.dll
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
android/local.properties
|
||||
*.log
|
||||
*.apk
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,104 +1,124 @@
|
||||
# Lunaris
|
||||
# 🌙 Lunaris
|
||||
|
||||
Lunaris — локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле.
|
||||
<p align="center">
|
||||
<img src="assets/branding/app_icon.png" width="128" height="128" alt="Lunaris Logo" />
|
||||
</p>
|
||||
|
||||
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
|
||||
<p align="center">
|
||||
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов</strong><br>
|
||||
Плавный Pinterest-стиль, поддержка фото, видео и GIF, умный черный список, встроенный менеджер загрузок и автообновление.
|
||||
</p>
|
||||
|
||||
## Что умеет
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||
</p>
|
||||
|
||||
- Masonry feed с несколькими провайдерами и естественно перемешанной выдачей.
|
||||
- Поиск тегов через chip-input с подсказками из активных API.
|
||||
- Избранное, история просмотров, коллекции и пакетное добавление в коллекции.
|
||||
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
||||
- Details viewer для фото, GIF, видео и SWF на Windows.
|
||||
- Удобный видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
||||
- Mobile image zoom: pinch-to-zoom и double tap в details.
|
||||
- Опциональное автоскачивание новых favorites с отдельным удалением локального файла без удаления из избранного.
|
||||
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычной booru-лентой.
|
||||
- Управление провайдерами, health-check и provider diagnostics.
|
||||
- Кастомизация интерфейса: тема, язык RU/EN, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
||||
- Экспорт и импорт настроек JSON.
|
||||
- Проверка Gitea Releases и скачивание APK/Windows installer прямо из приложения.
|
||||
---
|
||||
|
||||
## Скачать
|
||||
## ✨ Основные возможности
|
||||
|
||||
Готовые сборки лежат в Gitea Releases:
|
||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
|
||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
|
||||
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
|
||||
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
||||
|
||||
[Lunaris 2.0.1](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.1)
|
||||
---
|
||||
|
||||
- `Lunaris-v2.0.1.apk` — APK для Android.
|
||||
- `LunarisSetup-v2.0.1.exe` — установщик для Windows.
|
||||
- `LunarisPortable-v2.0.1.zip` — portable-версия для Windows.
|
||||
## 🌐 Активные провайдеры
|
||||
|
||||
## Провайдеры
|
||||
| Провайдер | Тип API | Назначение |
|
||||
|---|---|---|
|
||||
| **Gelbooru** | XML / JSON API | Основная база аниме-артов |
|
||||
| **Rule34** | Booru API | Популярный booru-источник |
|
||||
| **Realbooru** | HTML Scraper | Фото и реалистичный контент |
|
||||
| **e621 / e926** | JSON API | Фурри- и антро-арт с богатой системой тегов |
|
||||
| **Pawchive** | REST API v1 | Архивы авторов Patreon, Pixiv Fanbox, Fantia и др. |
|
||||
|
||||
Обычный feed:
|
||||
---
|
||||
|
||||
- Gelbooru
|
||||
- Rule34
|
||||
- Safebooru
|
||||
- Konachan
|
||||
- Yande.re
|
||||
- e621 / e926
|
||||
- Xbooru
|
||||
- CosBooru
|
||||
- Realbooru отключен по умолчанию, если публичный API недоступен
|
||||
## 📥 Скачать приложение
|
||||
|
||||
Artists flow:
|
||||
Готовые сборки для всех поддерживаемых платформ доступны на странице релизов:
|
||||
|
||||
- Kemono
|
||||
- Coomer
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
Kemono/Coomer остаются отдельным разделом Artists и не попадают в общий booru feed.
|
||||
- **Android**: `Lunaris-v2.0.2.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip`
|
||||
|
||||
## Архитектура
|
||||
---
|
||||
|
||||
- `lib/core` — HTTP, база, кеш, ошибки, Result и утилиты.
|
||||
- `lib/backend/models` — доменные модели.
|
||||
- `lib/backend/providers` — API-провайдеры и ProviderManager.
|
||||
- `lib/backend/repositories` — доступ к локальному Isar-хранилищу.
|
||||
- `lib/backend/services` — Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
||||
- `lib/backend/di` — Riverpod providers для backend/core.
|
||||
- `lib/app` — приложение, роутер, тема, локализация, motion и responsive helpers.
|
||||
- `lib/shared/widgets` — общие UI-компоненты.
|
||||
- `lib/features` — экраны и контроллеры фич.
|
||||
## 🛠️ Сборка из исходников
|
||||
|
||||
## Запуск
|
||||
### Требования
|
||||
- Flutter SDK `>= 3.19.0`
|
||||
- OpenJDK 17
|
||||
- Android SDK (для сборки APK)
|
||||
- Clang / CMake / GTK-3 (для сборки под Linux)
|
||||
|
||||
### Команды разработки
|
||||
|
||||
```bash
|
||||
# Клонирование репозитория
|
||||
git clone https://gitea.rardogsynapse.online/RarDog/Lunaris.git
|
||||
cd Lunaris
|
||||
|
||||
# Установка зависимостей
|
||||
flutter pub get
|
||||
flutter run -d windows
|
||||
flutter run -d android
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Запуск тестов и анализатора
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter build apk
|
||||
flutter build windows
|
||||
|
||||
# Запуск на подключенном устройстве
|
||||
flutter run
|
||||
```
|
||||
|
||||
Windows installer собирается через Inno Setup:
|
||||
### Сборка релизов
|
||||
|
||||
```powershell
|
||||
ISCC.exe installer\Lunaris.iss
|
||||
```bash
|
||||
# Android APK
|
||||
flutter build apk --release
|
||||
|
||||
# Linux x64 Bundle
|
||||
flutter build linux --release
|
||||
|
||||
# Windows Bundle
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
## Как добавить провайдера
|
||||
---
|
||||
|
||||
1. Создать класс, реализующий `ContentProvider`.
|
||||
2. Добавить mapper к единой модели `Post`.
|
||||
3. Зарегистрировать `apiType` в `ProviderFactory`.
|
||||
4. Добавить seed config в `ProviderRepository`, если провайдер нужен по умолчанию.
|
||||
5. Добавить тесты parser/provider factory.
|
||||
## 🏛️ Архитектура
|
||||
|
||||
## Безопасность
|
||||
```
|
||||
lib/
|
||||
├── app/ # Роутер, тема, локализация (RU/EN), анимации
|
||||
├── backend/
|
||||
│ ├── di/ # Riverpod providers для сервисов
|
||||
│ ├── models/ # Post, ArtistProfile, AppSettings, AppUpdateInfo
|
||||
│ ├── providers/ # Реализации API (Gelbooru, Rule34, Pawchive и др.)
|
||||
│ ├── repositories/ # Доступ к локальной БД Isar
|
||||
│ └── services/ # Бизнес-логика (Feed, Search, Favorites, Downloads, Updates)
|
||||
├── core/ # Сеть (Dio), база данных (Isar), ошибки и кеши
|
||||
├── features/ # Модули экранов (feed, post, artists, favorites, settings)
|
||||
└── shared/widgets/ # Переиспользуемые виджеты (PostCard, MasonryGrid, TagChip)
|
||||
```
|
||||
|
||||
- Feed работает только с metadata/preview и не скачивает original/full files автоматически.
|
||||
- Загрузка файлов происходит только по явному действию пользователя или при включенной настройке auto-download favorites.
|
||||
- Приложение уважает API и rate limits сайтов.
|
||||
- NSFW/blur/blacklist настраиваются локально.
|
||||
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
|
||||
- Локальные пути скачанных файлов не попадают в JSON export/import.
|
||||
---
|
||||
|
||||
## 🔒 Приватность и безопасность
|
||||
|
||||
- **Полная автономность**: Приложение не отправляет телеметрию и работает без внешних серверов-посредников.
|
||||
- **Локальное хранение**: Все настройки, история поиска, черный список и избранное хранятся исключительно на вашем устройстве в локальной базе Isar.
|
||||
- **Бережное отношение к трафику**: В ленте загружаются только оптимизированные превью. Тяжелые оригиналы и видео загружаются только при открытии или скачивании.
|
||||
|
||||
@@ -3,6 +3,10 @@ include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- build/**
|
||||
- android/**
|
||||
- windows/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
+56
-1
@@ -221,6 +221,19 @@ class _DownloadPanel extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final manager = ref.read(downloadManagerServiceProvider);
|
||||
final paused = manager.queuePaused;
|
||||
final activeTasks = tasks
|
||||
.where((task) =>
|
||||
task.status == DownloadTaskStatus.queued ||
|
||||
task.status == DownloadTaskStatus.running)
|
||||
.toList();
|
||||
final finishedTasks = tasks
|
||||
.where((task) =>
|
||||
task.status == DownloadTaskStatus.completed ||
|
||||
task.status == DownloadTaskStatus.failed ||
|
||||
task.status == DownloadTaskStatus.canceled)
|
||||
.toList();
|
||||
return Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -232,6 +245,39 @@ class _DownloadPanel extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
strings.download,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: paused ? 'Resume queue' : 'Pause queue',
|
||||
onPressed:
|
||||
paused ? manager.resumeQueue : manager.pauseQueue,
|
||||
icon: Icon(paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded),
|
||||
),
|
||||
if (activeTasks.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Cancel active',
|
||||
onPressed: () {
|
||||
for (final task in activeTasks) {
|
||||
manager.cancel(task.id);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.cancel_rounded),
|
||||
),
|
||||
if (finishedTasks.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Clear finished',
|
||||
onPressed: manager.clearFinished,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final task in tasks) ...[
|
||||
Row(
|
||||
children: [
|
||||
@@ -252,6 +298,15 @@ class _DownloadPanel extends ConsumerWidget {
|
||||
.retry(task.id),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
if (task.status == DownloadTaskStatus.queued ||
|
||||
task.status == DownloadTaskStatus.running)
|
||||
IconButton(
|
||||
tooltip: 'Cancel',
|
||||
onPressed: () => ref
|
||||
.read(downloadManagerServiceProvider)
|
||||
.cancel(task.id),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -344,7 +399,7 @@ class _StartupErrorScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
strings.ru
|
||||
? 'Не удалось открыть локальную базу'
|
||||
? '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044c \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0431\u0430\u0437\u0443'
|
||||
: 'Could not start local database',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -75,8 +75,8 @@ class AppStrings {
|
||||
String get hideViewed => ru ? 'Скрывать просмотренное' : 'Hide viewed posts';
|
||||
String get smartBlacklist => ru ? 'Умный blacklist' : 'Smart blacklist';
|
||||
String get whitelistedTags => ru ? 'Whitelist тегов' : 'Whitelisted tags';
|
||||
String get cacheMaxItems => ru ? 'Лимит кэша' : 'Cache max items';
|
||||
String get clearCache => ru ? 'Очистить кэш' : 'Clear cache';
|
||||
String get cacheMaxItems => ru ? 'Лимит кеша' : 'Cache max items';
|
||||
String get clearCache => ru ? 'Очистить кеш' : 'Clear cache';
|
||||
String get clearViewed => ru ? 'Очистить историю' : 'Clear viewed history';
|
||||
String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON';
|
||||
String get importJson => ru ? 'Импорт JSON' : 'Import JSON';
|
||||
@@ -98,5 +98,5 @@ class AppStrings {
|
||||
String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat';
|
||||
String get fullscreen => ru ? 'На весь экран' : 'Fullscreen';
|
||||
String get exitFullscreen =>
|
||||
ru ? 'Выйти из полного экрана' : 'Exit fullscreen';
|
||||
ru ? 'Выйти из полноэкранного режима' : 'Exit fullscreen';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.1';
|
||||
const appBuildNumber = 22;
|
||||
const appDisplayVersion = '2.0.2';
|
||||
const appBuildNumber = 24;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
+19
-9
@@ -1,4 +1,16 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.2',
|
||||
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
||||
bullets: [
|
||||
'Full horizontal post pager across mobile and desktop in Feed, Artists, Favorites, Collections, and Viewed history.',
|
||||
'Eliminated video preview stutter and lag in grid feed by using high-performance cached thumbnails and on-demand playback.',
|
||||
'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.',
|
||||
'Multi-image posts now feature an in-post navigation indicator with image switching.',
|
||||
'New Pawchive provider support and cleaned up deprecated providers.',
|
||||
'In-app update checks and downloads connected directly to RarDog Gitea releases.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.1',
|
||||
title: 'Localization, zoom and favorite downloads',
|
||||
@@ -43,29 +55,27 @@ const lunarisChangelog = [
|
||||
),
|
||||
LunarisChange(
|
||||
version: '1.1.0',
|
||||
title: 'Lunaris rebrand and CosBooru',
|
||||
title: 'Lunaris rebrand and providers',
|
||||
bullets: [
|
||||
'RuleGel is now Lunaris while keeping the same package identity for updates.',
|
||||
'CosBooru now uses the working Danbooru-compatible API at cos.lycore.co.',
|
||||
'Xbooru was added as a default Gelbooru-compatible provider.',
|
||||
'Rule34 Paheal was removed from providers.',
|
||||
'Rule34 Paheal is available as an HTML provider.',
|
||||
'Video player UI preferences are saved across fullscreen, details and restarts.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '1.0.2',
|
||||
title: 'Realbooru safety fix',
|
||||
title: 'Provider safety fix',
|
||||
bullets: [
|
||||
'Realbooru stays available in Providers, but is disabled by default while its public DAPI is offline.',
|
||||
'Provider diagnostics now surface the Realbooru API reason instead of a generic exception.',
|
||||
'Existing enabled Realbooru configs are disabled on startup so the main feed is not polluted by a known-offline API.',
|
||||
'Known-offline legacy providers are disabled on startup.',
|
||||
'Provider diagnostics now surface API reasons instead of generic exceptions.',
|
||||
'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '1.0.1',
|
||||
title: 'Realbooru and mobile polish',
|
||||
title: 'Mobile polish',
|
||||
bullets: [
|
||||
'Added Realbooru as a default enabled provider.',
|
||||
'Startup update checks now look for new Gitea releases every launch.',
|
||||
'Refresh-rate controls are Android-only; desktop keeps stable app motion.',
|
||||
'Tag chip input no longer jumps while typing.',
|
||||
|
||||
+26
-5
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
|
||||
import '../features/providers/presentation/provider_form_screen.dart';
|
||||
import '../features/providers/presentation/providers_screen.dart';
|
||||
import '../features/search/presentation/search_screen.dart';
|
||||
import '../features/settings/presentation/hidden_posts_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
import '../features/viewed/presentation/viewed_screen.dart';
|
||||
import '../shared/widgets/app_shell.dart';
|
||||
@@ -39,11 +40,27 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) => PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||
),
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
if (extra is PostNavigationContext) {
|
||||
initialPost = extra.currentPost;
|
||||
postsList = extra.posts;
|
||||
} else if (extra is Post) {
|
||||
initialPost = extra;
|
||||
postsList = null;
|
||||
} else {
|
||||
initialPost = null;
|
||||
postsList = null;
|
||||
}
|
||||
return PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: initialPost,
|
||||
postsList: postsList,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId/similar',
|
||||
@@ -105,6 +122,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/hidden',
|
||||
builder: (context, state) => const HiddenPostsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../models/download_task.dart';
|
||||
import '../models/downloaded_media.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/downloaded_media_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
@@ -188,6 +189,12 @@ final downloadedMediaByKeyProvider =
|
||||
return result is Success<DownloadedMedia?> ? result.data : null;
|
||||
});
|
||||
|
||||
final postMediaHeadersProvider =
|
||||
FutureProvider.family<Map<String, String>, Post>((ref, post) async {
|
||||
final result = await ref.watch(providerManagerProvider).getMediaHeaders(post);
|
||||
return result is Success<Map<String, String>> ? result.data : const {};
|
||||
});
|
||||
|
||||
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
|
||||
final manager = ref.watch(downloadManagerServiceProvider);
|
||||
return manager.stream;
|
||||
|
||||
@@ -9,6 +9,7 @@ class AppUpdateInfo {
|
||||
this.apkUrl,
|
||||
this.windowsInstallerUrl,
|
||||
this.portableZipUrl,
|
||||
this.linuxTarGzUrl,
|
||||
});
|
||||
|
||||
final String version;
|
||||
@@ -20,7 +21,11 @@ class AppUpdateInfo {
|
||||
final String? apkUrl;
|
||||
final String? windowsInstallerUrl;
|
||||
final String? portableZipUrl;
|
||||
final String? linuxTarGzUrl;
|
||||
|
||||
bool get hasAssets =>
|
||||
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
|
||||
apkUrl != null ||
|
||||
windowsInstallerUrl != null ||
|
||||
portableZipUrl != null ||
|
||||
linuxTarGzUrl != null;
|
||||
}
|
||||
|
||||
@@ -114,3 +114,14 @@ class Post {
|
||||
const {},
|
||||
);
|
||||
}
|
||||
|
||||
class PostNavigationContext {
|
||||
const PostNavigationContext({
|
||||
required this.currentPost,
|
||||
required this.posts,
|
||||
});
|
||||
|
||||
final Post currentPost;
|
||||
final List<Post> posts;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ abstract class ContentProvider {
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
|
||||
abstract class PostPageProvider {
|
||||
String? postPageUrl(Post post);
|
||||
}
|
||||
|
||||
abstract class MediaHeadersProvider {
|
||||
Map<String, String> mediaHeaders(Post post);
|
||||
}
|
||||
|
||||
abstract class CommentProvider {
|
||||
Future<List<PostComment>> getComments(String postId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
class DanbooruProvider
|
||||
implements ContentProvider, CommentProvider, PostPageProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class E621Provider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
E621Provider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class E621Provider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -15,7 +15,9 @@ class GelbooruProvider
|
||||
ContentProvider,
|
||||
CommentProvider,
|
||||
TagSuggestionProvider,
|
||||
TagMetadataProvider {
|
||||
TagMetadataProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -36,6 +38,20 @@ class GelbooruProvider
|
||||
Dio get dio => _dio;
|
||||
Map<String, String> get queryParameters => _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) {
|
||||
final host = Uri.tryParse(baseUrl)?.host;
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -106,10 +106,15 @@ class KemonoProvider
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null || service.isEmpty || artist.service == service)
|
||||
.toList(growable: false);
|
||||
final artists = _rankArtists(
|
||||
_artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service == service)
|
||||
.toList(growable: false),
|
||||
searchText,
|
||||
);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
@@ -126,7 +131,7 @@ class KemonoProvider
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _artistsFromResponse(response.data);
|
||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
@@ -177,6 +182,7 @@ class KemonoProvider
|
||||
));
|
||||
}
|
||||
if (mapped.isNotEmpty || posts.isEmpty) {
|
||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -290,7 +296,36 @@ class KemonoProvider
|
||||
artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText)))
|
||||
.toList(growable: false);
|
||||
return items.skip(offset).take(limit).toList(growable: false);
|
||||
final ranked = _rankArtists(items, queryText);
|
||||
return ranked.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
|
||||
final queryText = query.trim().toLowerCase();
|
||||
final ranked = [...artists];
|
||||
ranked.sort((a, b) {
|
||||
final scoreA = _artistSearchScore(a, queryText);
|
||||
final scoreB = _artistSearchScore(b, queryText);
|
||||
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
|
||||
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updated = updatedB.compareTo(updatedA);
|
||||
if (updated != 0) return updated;
|
||||
final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0);
|
||||
if (count != 0) return count;
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
|
||||
int _artistSearchScore(ArtistProfile artist, String query) {
|
||||
if (query.isEmpty) return 0;
|
||||
final name = artist.displayName.toLowerCase();
|
||||
final id = artist.id.toLowerCase();
|
||||
if (name == query || id == query) return 100;
|
||||
if (name.startsWith(query) || id.startsWith(query)) return 70;
|
||||
if (name.contains(query) || id.contains(query)) return 40;
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<dynamic> _postItems(dynamic data) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class MoebooruProvider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
MoebooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class MoebooruProvider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class PawchiveProvider
|
||||
implements ContentProvider, ArtistProvider, CommentProvider {
|
||||
PawchiveProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final String name;
|
||||
|
||||
@override
|
||||
final String baseUrl;
|
||||
|
||||
final Dio _dio;
|
||||
List<ArtistProfile>? _cachedArtists;
|
||||
DateTime? _lastCreatorsFetchAt;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final parts = id.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
final service = parts[0];
|
||||
final artistId = parts[1];
|
||||
final postId = parts[2];
|
||||
final targetIndex = parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0;
|
||||
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: this.id,
|
||||
service: service,
|
||||
artistId: artistId,
|
||||
artistName: artistId,
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/post/$postId',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is Map) {
|
||||
final posts = _postsFromArtistPost(
|
||||
Map<String, dynamic>.from(data),
|
||||
query: query,
|
||||
);
|
||||
if (targetIndex >= 0 && targetIndex < posts.length) {
|
||||
return posts[targetIndex];
|
||||
}
|
||||
if (posts.isNotEmpty) return posts.first;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback: search within artist posts
|
||||
try {
|
||||
final posts = await getArtistPosts(query: query, page: 0, limit: 50);
|
||||
for (final post in posts) {
|
||||
if (post.id == id) return post;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/app_version',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final version = response.data?.toString().trim() ?? 'ok';
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'pawchive-$version',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'pawchive',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final allArtists = await _ensureArtistsLoaded();
|
||||
final queryText = (query ?? '').trim().toLowerCase();
|
||||
final filtered = allArtists.where((artist) {
|
||||
final matchService = service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service.toLowerCase() == service.toLowerCase();
|
||||
if (!matchService) return false;
|
||||
if (queryText.isEmpty) return true;
|
||||
return artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText);
|
||||
}).toList(growable: false);
|
||||
|
||||
final ranked = _rankArtists(filtered, queryText);
|
||||
final offset = (page - 1).clamp(0, 999999) * limit;
|
||||
return ranked.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> getArtistPosts({
|
||||
required ArtistWorkQuery query,
|
||||
int page = 0,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
final path = '/api/v1/${query.service}/user/${query.artistId}/posts';
|
||||
final offset = page * limit;
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: {'o': offset},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
for (final post in posts.whereType<Map>()) {
|
||||
mapped.addAll(_postsFromArtistPost(
|
||||
Map<String, dynamic>.from(post),
|
||||
query: query,
|
||||
));
|
||||
}
|
||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
} catch (error) {
|
||||
throw StateError(
|
||||
'$name artist posts are unavailable right now. ${_shortError(error)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getArtistPostComments(
|
||||
String service,
|
||||
String artistId,
|
||||
String postId,
|
||||
) async {
|
||||
final path = '/api/v1/$service/user/$artistId/post/$postId/comments';
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(path);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
} catch (error) {
|
||||
// 404 means no comments found, return empty list
|
||||
if (error is DioException && error.response?.statusCode == 404) {
|
||||
return const [];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final parts = postId.split(':');
|
||||
if (parts.length < 3) return const [];
|
||||
return getArtistPostComments(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
Future<List<ArtistProfile>> _ensureArtistsLoaded() async {
|
||||
final cached = _cachedArtists;
|
||||
final lastFetch = _lastCreatorsFetchAt;
|
||||
if (cached != null &&
|
||||
lastFetch != null &&
|
||||
DateTime.now().difference(lastFetch) < const Duration(minutes: 30)) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/creators',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 25),
|
||||
sendTimeout: const Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
final artists = _artistsFromResponse(response.data);
|
||||
_cachedArtists = artists;
|
||||
_lastCreatorsFetchAt = DateTime.now();
|
||||
return artists;
|
||||
} catch (error) {
|
||||
if (cached != null) return cached;
|
||||
throw StateError('Failed to load $name creators: ${_shortError(error)}');
|
||||
}
|
||||
}
|
||||
|
||||
List<ArtistProfile> _artistsFromResponse(dynamic data) {
|
||||
final items = data is Map ? data['data'] : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
final service = (json['service'] ?? '').toString();
|
||||
final artistId =
|
||||
(json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '')
|
||||
.toString();
|
||||
final name = (json['name'] ??
|
||||
json['display_name'] ??
|
||||
json['username'] ??
|
||||
artistId)
|
||||
.toString();
|
||||
final iconUrl = '$baseUrl/icons/$service/$artistId';
|
||||
return ArtistProfile(
|
||||
id: artistId,
|
||||
providerId: id,
|
||||
service: service,
|
||||
name: name,
|
||||
displayName: name,
|
||||
avatarUrl: iconUrl,
|
||||
updatedAt: _dateFromAny(json['updated']),
|
||||
postCount: (json['favorited'] as num?)?.toInt(),
|
||||
url: '$baseUrl/$service/user/$artistId',
|
||||
);
|
||||
})
|
||||
.where((artist) => artist.id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Post> _postsFromArtistPost(
|
||||
Map<String, dynamic> json, {
|
||||
required ArtistWorkQuery query,
|
||||
}) {
|
||||
final postId = (json['id'] ?? json['post_id'] ?? '').toString();
|
||||
final title = (json['title'] ?? '').toString();
|
||||
final published = _dateFromAny(json['published']) ??
|
||||
_dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['edited']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final files = <Map<String, dynamic>>[];
|
||||
final file = json['file'];
|
||||
if (file is Map && file.isNotEmpty) {
|
||||
files.add(Map<String, dynamic>.from(file));
|
||||
}
|
||||
final attachments = json['attachments'];
|
||||
if (attachments is List) {
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
return files
|
||||
.map((fileMap) {
|
||||
final rawPath = (fileMap['path'] ?? '').toString();
|
||||
final fileName = (fileMap['name'] ?? '').toString();
|
||||
final previewOnly = fileMap['preview_only'] == true;
|
||||
|
||||
final previewUrl = _thumbnailUrl(rawPath);
|
||||
final fileUrl = previewOnly
|
||||
? previewUrl
|
||||
: _fileDownloadUrl(rawPath, fileName);
|
||||
|
||||
final type = _fileType('$fileName $rawPath');
|
||||
final stableId =
|
||||
'${query.service}:${query.artistId}:$postId:${index++}';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
];
|
||||
|
||||
return Post(
|
||||
id: stableId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: previewUrl,
|
||||
fileUrl: fileUrl,
|
||||
tags: tags,
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source:
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
|
||||
createdAt: published,
|
||||
fileType: type,
|
||||
score: 0,
|
||||
tagGroups: {
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
},
|
||||
);
|
||||
})
|
||||
.where((post) => post.fileUrl.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _thumbnailUrl(String path) {
|
||||
if (path.isEmpty) return '';
|
||||
final trimmed = path.startsWith('/') ? path : '/$path';
|
||||
return 'https://img.pawchive.pw/thumbnail/data$trimmed';
|
||||
}
|
||||
|
||||
String _fileDownloadUrl(String path, String fileName) {
|
||||
if (path.isEmpty) return '';
|
||||
final trimmed = path.startsWith('/') ? path : '/$path';
|
||||
final base = 'https://file.pawchive.pw/data$trimmed';
|
||||
if (fileName.isEmpty) return base;
|
||||
return '$base?f=${Uri.encodeComponent(fileName)}';
|
||||
}
|
||||
|
||||
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
|
||||
final items = data is Map ? (data['comments'] ?? data['data']) : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? json['comment_id'] ?? '').toString(),
|
||||
postId: postId,
|
||||
providerId: id,
|
||||
authorName: (json['commenter'] ??
|
||||
json['author'] ??
|
||||
json['username'] ??
|
||||
'user')
|
||||
.toString(),
|
||||
body: (json['content'] ?? json['body'] ?? json['comment'] ?? '')
|
||||
.toString(),
|
||||
createdAt: _dateFromAny(json['published']) ??
|
||||
_dateFromAny(json['added']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.id.isNotEmpty && comment.body.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<dynamic> _postItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
final posts = data['posts'] ?? data['data'] ?? data['results'];
|
||||
if (posts is List) return posts;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
|
||||
final queryText = query.trim().toLowerCase();
|
||||
final ranked = [...artists];
|
||||
ranked.sort((a, b) {
|
||||
if (queryText.isNotEmpty) {
|
||||
final aExact = a.displayName.toLowerCase() == queryText;
|
||||
final bExact = b.displayName.toLowerCase() == queryText;
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
final aStarts = a.displayName.toLowerCase().startsWith(queryText);
|
||||
final bStarts = b.displayName.toLowerCase().startsWith(queryText);
|
||||
if (aStarts && !bStarts) return -1;
|
||||
if (!aStarts && bStarts) return 1;
|
||||
}
|
||||
final favA = a.postCount ?? 0;
|
||||
final favB = b.postCount ?? 0;
|
||||
if (favA != favB) return favB.compareTo(favA);
|
||||
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return updatedB.compareTo(updatedA);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
|
||||
String _shortError(Object? error) {
|
||||
if (error == null) return '';
|
||||
final message = error.toString();
|
||||
if (message.length <= 160) return message;
|
||||
return '${message.substring(0, 160)}...';
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
||||
return 'video';
|
||||
}
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.zip') || lower.endsWith('.rar') || lower.endsWith('.7z')) {
|
||||
return 'archive';
|
||||
}
|
||||
if (lower.endsWith('.png') ||
|
||||
lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
DateTime? _dateFromAny(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final raw = value.toInt();
|
||||
if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
return DateTime.fromMillisecondsSinceEpoch(raw * 1000);
|
||||
}
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import 'e621_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'kemono_provider.dart';
|
||||
import 'moebooru_provider.dart';
|
||||
import 'realbooru_provider.dart';
|
||||
import 'pawchive_provider.dart';
|
||||
import 'realbooru_html_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
import 'rule34_paheal_provider.dart';
|
||||
|
||||
class ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) {
|
||||
@@ -41,13 +43,20 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'realbooru':
|
||||
return RealbooruProvider(
|
||||
case 'paheal':
|
||||
case 'rule34_paheal':
|
||||
return Rule34PahealProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
);
|
||||
case 'realbooru_html':
|
||||
return RealbooruHtmlProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'danbooru':
|
||||
return DanbooruProvider(
|
||||
@@ -82,6 +91,14 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'pawchive':
|
||||
return PawchiveProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
default:
|
||||
return UnsupportedCustomProvider(
|
||||
id: config.id,
|
||||
|
||||
@@ -18,12 +18,30 @@ class ProviderManager {
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
|
||||
final Set<String> _softRetryKeys = {};
|
||||
final _softRetryController =
|
||||
StreamController<ProviderSoftRetryResult>.broadcast();
|
||||
_SuggestionProviderCacheEntry? _suggestionProviderCache;
|
||||
|
||||
static const _suggestionCacheTtl = Duration(seconds: 45);
|
||||
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
|
||||
static const _suggestionTimeout = Duration(milliseconds: 1500);
|
||||
|
||||
Stream<ProviderSoftRetryResult> get softRetryResults =>
|
||||
_softRetryController.stream;
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
await _repository.ensureSeedProviders();
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
final result = await _repository.getProviders(enabledOnly: enabledOnly);
|
||||
if (result is Error<List<ContentProviderConfig>>) return result;
|
||||
final configs = (result as Success<List<ContentProviderConfig>>)
|
||||
.data
|
||||
.where((config) => !_isLegacyRemovedConfig(config))
|
||||
.toList(growable: false);
|
||||
return Success(configs);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
|
||||
@@ -97,20 +115,34 @@ class ProviderManager {
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
return _repository.saveProvider(
|
||||
return _repository
|
||||
.saveProvider(
|
||||
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
|
||||
);
|
||||
)
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
},
|
||||
onError: Error<void>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
|
||||
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
|
||||
return _repository
|
||||
.saveProvider(config.copyWith(updatedAt: DateTime.now()))
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) =>
|
||||
_repository.deleteProvider(id);
|
||||
Future<Result<void>> deleteProvider(String id) {
|
||||
return _repository.deleteProvider(id).then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
@@ -186,6 +218,14 @@ class ProviderManager {
|
||||
errorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
_scheduleSoftSearchRetry(
|
||||
provider,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
final posts = providerId == null
|
||||
@@ -228,6 +268,75 @@ class ProviderManager {
|
||||
return hash;
|
||||
}
|
||||
|
||||
void _scheduleSoftSearchRetry(
|
||||
ContentProvider provider, {
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
required int limit,
|
||||
required String? rating,
|
||||
required TopPeriodFilter topPeriod,
|
||||
}) {
|
||||
final key = [
|
||||
provider.id,
|
||||
page,
|
||||
limit,
|
||||
rating ?? '',
|
||||
topPeriod.name,
|
||||
...tags,
|
||||
].join('\u001f');
|
||||
if (!_softRetryKeys.add(key)) return;
|
||||
unawaited(Future<void>.delayed(const Duration(milliseconds: 900), () async {
|
||||
try {
|
||||
final posts = await provider.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: posts.length,
|
||||
),
|
||||
);
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (posts.isNotEmpty) {
|
||||
_softRetryController.add(
|
||||
ProviderSoftRetryResult(
|
||||
providerId: provider.id,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
posts: posts,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: 0,
|
||||
lastErrorMessage: 'Search failed after retry',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_softRetryKeys.remove(key);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Result<List<PostComment>>> getComments(
|
||||
String providerId,
|
||||
String postId,
|
||||
@@ -282,6 +391,39 @@ class ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<String?>> getPostPageUrl(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isEmpty || matches.first is! PostPageProvider) {
|
||||
return const Success(null);
|
||||
}
|
||||
return Success((matches.first as PostPageProvider).postPageUrl(post));
|
||||
}
|
||||
|
||||
Future<Result<Map<String, String>>> getMediaHeaders(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isNotEmpty && matches.first is MediaHeadersProvider) {
|
||||
return Success(
|
||||
(matches.first as MediaHeadersProvider).mediaHeaders(post),
|
||||
);
|
||||
}
|
||||
return const Success({
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Post>> enrichPostTags(Post post) async {
|
||||
final hasUsefulGroups = post.tagGroups.entries.any(
|
||||
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
|
||||
@@ -313,30 +455,106 @@ class ProviderManager {
|
||||
String query, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return const Success([]);
|
||||
final providersResult = await activeSuggestionProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providers.isEmpty) return const Success([]);
|
||||
|
||||
final cappedLimit = limit.clamp(1, 50);
|
||||
final providerIds = providers.map((provider) => provider.id).join(',');
|
||||
final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit';
|
||||
final cached = _suggestionCache[cacheKey];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) {
|
||||
return Success(cached.items.take(cappedLimit).toList(growable: false));
|
||||
}
|
||||
|
||||
final providerResults = await Future.wait(
|
||||
providers.map(
|
||||
(provider) => _suggestionsFromProvider(
|
||||
provider as TagSuggestionProvider,
|
||||
normalizedQuery,
|
||||
cappedLimit,
|
||||
),
|
||||
),
|
||||
);
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final items in providerResults) {
|
||||
for (final item in items) {
|
||||
final nameKey = item.name.trim().toLowerCase();
|
||||
if (nameKey.isEmpty) continue;
|
||||
final current = suggestions[nameKey];
|
||||
if (current == null || item.postCount > current.postCount) {
|
||||
suggestions[nameKey] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) {
|
||||
final count = b.postCount.compareTo(a.postCount);
|
||||
return count != 0 ? count : a.name.compareTo(b.name);
|
||||
});
|
||||
_suggestionCache[cacheKey] = _SuggestionCacheEntry(
|
||||
DateTime.now(),
|
||||
values,
|
||||
);
|
||||
_pruneSuggestionCache();
|
||||
return Success(values.take(cappedLimit).toList());
|
||||
}
|
||||
|
||||
Future<List<TagSuggestion>> _suggestionsFromProvider(
|
||||
TagSuggestionProvider provider,
|
||||
String query,
|
||||
int limit,
|
||||
) async {
|
||||
try {
|
||||
return await provider
|
||||
.suggestTags(query, limit: limit)
|
||||
.timeout(_suggestionTimeout);
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
void _pruneSuggestionCache() {
|
||||
final now = DateTime.now();
|
||||
_suggestionCache.removeWhere(
|
||||
(_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeSuggestionProviders() async {
|
||||
final cached = _suggestionProviderCache;
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) <
|
||||
_suggestionProviderCacheTtl) {
|
||||
return Success(cached.providers);
|
||||
}
|
||||
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>)
|
||||
.data
|
||||
.whereType<TagSuggestionProvider>()
|
||||
.toList();
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final items = await provider.suggestTags(query, limit: limit);
|
||||
for (final item in items) {
|
||||
suggestions.putIfAbsent(
|
||||
'${item.providerId}:${item.name}', () => item);
|
||||
}
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
}
|
||||
if (suggestions.length >= limit) break;
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
||||
return Success(values.take(limit).toList());
|
||||
final providers = <ContentProvider>[
|
||||
for (final provider
|
||||
in (providersResult as Success<List<ContentProvider>>).data)
|
||||
if (provider is TagSuggestionProvider) provider,
|
||||
];
|
||||
_suggestionProviderCache = _SuggestionProviderCacheEntry(
|
||||
DateTime.now(),
|
||||
providers,
|
||||
);
|
||||
return Success(providers);
|
||||
}
|
||||
|
||||
void _clearSuggestionCaches() {
|
||||
_suggestionCache.clear();
|
||||
_suggestionProviderCache = null;
|
||||
}
|
||||
|
||||
Future<List<R>> _runLimited<T, R>(
|
||||
@@ -362,10 +580,50 @@ class ProviderManager {
|
||||
|
||||
static bool _isArtistConfig(ContentProviderConfig config) {
|
||||
final type = config.apiType.toLowerCase();
|
||||
return type == 'kemono' || type == 'coomer';
|
||||
return type == 'kemono' || type == 'coomer' || type == 'pawchive';
|
||||
}
|
||||
|
||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||
return !_isArtistConfig(config);
|
||||
}
|
||||
|
||||
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
||||
final id = config.id.toLowerCase();
|
||||
final type = config.apiType.toLowerCase();
|
||||
return id == 'cosbooru' || type == 'realbooru';
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionCacheEntry {
|
||||
const _SuggestionCacheEntry(this.createdAt, this.items);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<TagSuggestion> items;
|
||||
}
|
||||
|
||||
class _SuggestionProviderCacheEntry {
|
||||
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<ContentProvider> providers;
|
||||
}
|
||||
|
||||
class ProviderSoftRetryResult {
|
||||
const ProviderSoftRetryResult({
|
||||
required this.providerId,
|
||||
required this.tags,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.topPeriod,
|
||||
required this.posts,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final List<String> tags;
|
||||
final int page;
|
||||
final int limit;
|
||||
final TopPeriodFilter topPeriod;
|
||||
final String? rating;
|
||||
final List<Post> posts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class RealbooruHtmlProvider
|
||||
implements
|
||||
ContentProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider,
|
||||
TagSuggestionProvider {
|
||||
RealbooruHtmlProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
if (tags.isEmpty) 'all' else ...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
].join(' ');
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'post',
|
||||
's': 'list',
|
||||
'tags': queryTags,
|
||||
'pid': page * limit,
|
||||
},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {'page': 'post', 's': 'view', 'id': id},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseDetails(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
|
||||
'Accept':
|
||||
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer': postPageUrl(post),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'autocomplete',
|
||||
'term': trimmed,
|
||||
},
|
||||
);
|
||||
final values = _autocompleteItems(response.data);
|
||||
return values
|
||||
.map((name) => TagSuggestion(
|
||||
name: name,
|
||||
category: TagCategory.unknown,
|
||||
postCount: 0,
|
||||
providerId: id,
|
||||
))
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
trimmed.toLowerCase(),
|
||||
))
|
||||
.take(limit.clamp(1, 50))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final id = match.group(1) ?? '';
|
||||
final imgTag = match.group(0) ?? '';
|
||||
final previewUrl = _absolute(_decode(match.group(3) ?? ''));
|
||||
final tags = _tags(_attribute(imgTag, 'title'));
|
||||
final isVideo = imgTag.contains('#0000ff') ||
|
||||
tags.contains('webm') ||
|
||||
tags.contains('video');
|
||||
final isGif =
|
||||
!isVideo && (tags.contains('gif') || tags.contains('animated_gif'));
|
||||
final derivedOriginal =
|
||||
isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif);
|
||||
posts.add(
|
||||
Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postPageUrl(Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
)),
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
),
|
||||
);
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
Post? _parseDetails(String postId, String html) {
|
||||
final imageTag = RegExp(
|
||||
r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(0);
|
||||
final originalUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final videoSources = RegExp(
|
||||
r'''<source[^>]+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
)
|
||||
.allMatches(html)
|
||||
.map((match) => _absolute(match.group(1) ?? ''))
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList();
|
||||
videoSources.sort((left, right) {
|
||||
final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm');
|
||||
final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm');
|
||||
if (leftWebm == rightWebm) return 0;
|
||||
return leftWebm ? -1 : 1;
|
||||
});
|
||||
final imageUrl = _absolute(_attribute(imageTag ?? '', 'src'));
|
||||
final mediaUrl = videoSources.isNotEmpty
|
||||
? videoSources.first
|
||||
: originalUrl.isNotEmpty
|
||||
? originalUrl
|
||||
: imageUrl;
|
||||
final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl;
|
||||
final source = _attribute(
|
||||
RegExp(r'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
|
||||
.firstMatch(html)
|
||||
?.group(0) ??
|
||||
'',
|
||||
'value',
|
||||
);
|
||||
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
|
||||
? _attribute(imageTag ?? '', 'alt')
|
||||
: RegExp(
|
||||
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final tags = _tags(tagsText);
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: source.isEmpty ? postPageUrlById(postId) : source,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
);
|
||||
}
|
||||
|
||||
String postPageUrlById(String postId) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=$postId';
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
final match = RegExp(
|
||||
'''$name\\s*=\\s*["']([^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source);
|
||||
return _decode(match?.group(1) ?? '');
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, List<String>> _tagGroups(List<String> tags) =>
|
||||
tags.isEmpty ? const {} : {'general': tags};
|
||||
|
||||
String _fileType(String url) {
|
||||
final lower = url.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.startsWith('//')) return _normalizeUrl('https:$value');
|
||||
if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value');
|
||||
return _normalizeUrl(value);
|
||||
}
|
||||
|
||||
String _normalizeUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url;
|
||||
final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/');
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) {
|
||||
final uri = Uri.tryParse(thumbnailUrl);
|
||||
if (uri == null) return '';
|
||||
final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.')
|
||||
.firstMatch(uri.path);
|
||||
if (match == null) return '';
|
||||
final extension = gif ? 'gif' : 'jpeg';
|
||||
final path =
|
||||
'/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension';
|
||||
return uri.replace(path: path, query: '').toString();
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
List<String> _autocompleteItems(dynamic data) {
|
||||
final source = data is String ? jsonDecode(data) : data;
|
||||
if (source is List) {
|
||||
return source
|
||||
.map((item) => item.toString().trim().replaceAll(' ', '_'))
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
class RealbooruProvider extends GelbooruProvider {
|
||||
RealbooruProvider({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.baseUrl,
|
||||
required super.dioClient,
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
|
||||
];
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'pid': page,
|
||||
'limit': limit.clamp(1, 100),
|
||||
'tags': queryTags.join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'id': id,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'limit': 1,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfSearchFailed(dynamic data) {
|
||||
if (data is Map) {
|
||||
final success = data['success']?.toString().toLowerCase();
|
||||
if (success == 'false') {
|
||||
throw ProviderUnavailableException(
|
||||
(data['message'] ??
|
||||
data['reason'] ??
|
||||
'Realbooru search is unavailable')
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
final text = data?.toString().toLowerCase() ?? '';
|
||||
if (text.contains('success="false"') || text.contains("success='false'")) {
|
||||
final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''')
|
||||
.firstMatch(data?.toString() ?? '');
|
||||
throw ProviderUnavailableException(
|
||||
message?.group(1) ?? 'Realbooru search is unavailable',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class Rule34PahealProvider implements ContentProvider, PostPageProvider {
|
||||
Rule34PahealProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
// Paheal's public HTML list ignores common sort/top query parameters and
|
||||
// exposes no stable top-period endpoint, so non-latest filters fall back
|
||||
// to the regular list instead of pretending to support ranking.
|
||||
final query = tags
|
||||
.where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all')
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.join('_');
|
||||
final path = query.isEmpty
|
||||
? '/post/list/${page + 1}'
|
||||
: '/post/list/$query/${page + 1}';
|
||||
final response = await _dio.get<String>(
|
||||
path,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/post/view/$id',
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseSingle(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const [], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'paheal-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'paheal-html',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}';
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final block = match.group(0) ?? '';
|
||||
final postId = _attribute(block, 'data-post-id');
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(block)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(r'''<img[^>]+src=['"]([^'"]+)['"]''', caseSensitive: false)
|
||||
.firstMatch(block)
|
||||
?.group(1) ??
|
||||
'',
|
||||
);
|
||||
if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue;
|
||||
final tags = _tags(_attribute(block, 'data-tags'));
|
||||
final ext = _attribute(block, 'data-ext');
|
||||
final dimensions = _dimensions(_attribute(block, 'title'));
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
posts.add(
|
||||
Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: dimensions.$1,
|
||||
height: dimensions.$2,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType('$ext $mediaUrl'),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (posts.isNotEmpty) return posts;
|
||||
final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1);
|
||||
final single = singleId == null ? null : _parseSingle(singleId, html);
|
||||
return single == null ? const [] : [single];
|
||||
}
|
||||
|
||||
Post? _parseSingle(String postId, String html) {
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
RegExp(
|
||||
r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final tagsText = RegExp(
|
||||
r'''<meta[^>]+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
final tags = _tags(tagsText);
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
);
|
||||
}
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
return _decode(
|
||||
RegExp(
|
||||
'''$name\\s*=\\s*['"]([^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
(int, int) _dimensions(String value) {
|
||||
final match = RegExp(r'(\d+)x(\d+)').firstMatch(value);
|
||||
return (
|
||||
int.tryParse(match?.group(1) ?? '') ?? 0,
|
||||
int.tryParse(match?.group(2) ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.contains('webm') || lower.contains('mp4')) return 'video';
|
||||
if (lower.contains('gif')) return 'gif';
|
||||
if (lower.contains('png')) return 'png';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.isEmpty) return '';
|
||||
if (value.startsWith('//')) return 'https:$value';
|
||||
if (value.startsWith('/')) return '$baseUrl$value';
|
||||
return value;
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider {
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -40,10 +40,10 @@ class ProviderRepository {
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'safebooru',
|
||||
name: 'Safebooru',
|
||||
baseUrl: 'https://safebooru.org',
|
||||
apiType: 'gelbooru',
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
timeoutSeconds: 20,
|
||||
@@ -51,44 +51,16 @@ class ProviderRepository {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'konachan',
|
||||
name: 'Konachan',
|
||||
baseUrl: 'https://konachan.com',
|
||||
apiType: 'moebooru',
|
||||
enabled: true,
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'yandere',
|
||||
name: 'Yande.re',
|
||||
baseUrl: 'https://yande.re',
|
||||
apiType: 'moebooru',
|
||||
enabled: true,
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.api_version': '2',
|
||||
'query.include_tags': '1',
|
||||
'query.filter': '1',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'e621',
|
||||
name: 'e621',
|
||||
baseUrl: 'https://e621.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -99,78 +71,26 @@ class ProviderRepository {
|
||||
baseUrl: 'https://e926.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 6,
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru',
|
||||
enabled: false,
|
||||
priority: 7,
|
||||
id: 'pawchive',
|
||||
name: 'Pawchive',
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
apiType: 'pawchive',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'xbooru',
|
||||
name: 'Xbooru',
|
||||
baseUrl: 'https://xbooru.com',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 8,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'cosbooru',
|
||||
name: 'CosBooru',
|
||||
baseUrl: 'https://cos.lycore.co',
|
||||
apiType: 'danbooru',
|
||||
enabled: true,
|
||||
priority: 9,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'kemono',
|
||||
name: 'Kemono',
|
||||
baseUrl: 'https://kemono.su',
|
||||
apiType: 'kemono',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'coomer',
|
||||
name: 'Coomer',
|
||||
baseUrl: 'https://coomer.su',
|
||||
apiType: 'coomer',
|
||||
enabled: false,
|
||||
priority: 11,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -178,35 +98,24 @@ class ProviderRepository {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
const removedIds = {
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'xbooru',
|
||||
'paheal',
|
||||
'kemono',
|
||||
'coomer',
|
||||
};
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'paheal') {
|
||||
if (provider.providerId == 'cosbooru') {
|
||||
provider.enabled = false;
|
||||
provider.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(provider);
|
||||
} else if (removedIds.contains(provider.providerId)) {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
}
|
||||
}
|
||||
await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.providerDiagnosticsEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.favoriteEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.viewedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
final seeds = seedProviders();
|
||||
for (final seed in seeds) {
|
||||
final exists = await isar.providerConfigEntitys
|
||||
@@ -216,13 +125,10 @@ class ProviderRepository {
|
||||
if (exists == null) {
|
||||
await isar.providerConfigEntitys
|
||||
.put(ProviderConfigEntity.fromModel(seed));
|
||||
} else if (seed.id == 'realbooru' && exists.enabled) {
|
||||
exists.enabled = false;
|
||||
exists.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(exists);
|
||||
} else if (seed.id == 'cosbooru' &&
|
||||
(exists.baseUrl != seed.baseUrl ||
|
||||
exists.apiType != seed.apiType)) {
|
||||
} else if (seed.id == 'realbooru' &&
|
||||
(exists.apiType != seed.apiType ||
|
||||
exists.baseUrl != seed.baseUrl ||
|
||||
exists.name != seed.name)) {
|
||||
exists.name = seed.name;
|
||||
exists.baseUrl = seed.baseUrl;
|
||||
exists.apiType = seed.apiType;
|
||||
|
||||
@@ -23,11 +23,12 @@ class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
|
||||
Future<Result<List<SearchHistory>>> recent({int? limit}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.searchHistoryEntitys.where().findAll();
|
||||
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return items.take(limit).map((entity) => entity.toModel()).toList();
|
||||
final limited = limit == null ? items : items.take(limit);
|
||||
return limited.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +41,21 @@ class AppSettings {
|
||||
required this.videoPlayerHalfVolume,
|
||||
required this.videoPlayerLoop,
|
||||
required this.videoPlayerCover,
|
||||
required this.videoPlaybackPositions,
|
||||
required this.hiddenPostKeys,
|
||||
required this.diagnosticLogLines,
|
||||
required this.lastFeedTags,
|
||||
required this.lastFeedProviderIds,
|
||||
required this.lastFeedTopPeriod,
|
||||
required this.lastFeedScrollOffset,
|
||||
this.favoriteArtists = const [],
|
||||
this.skippedUpdateVersion,
|
||||
this.lastUpdateCheckAt,
|
||||
this.defaultRatingFilter,
|
||||
this.lastFeedRating,
|
||||
});
|
||||
|
||||
final List<String> favoriteArtists;
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
@@ -82,6 +85,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;
|
||||
@@ -94,11 +98,12 @@ class AppSettings {
|
||||
final String? lastFeedRating;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||
nsfwEnabled: true,
|
||||
cacheTtlHours: 24,
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
|
||||
favoriteArtists: [],
|
||||
themeMode: 'dark',
|
||||
languageCode: 'ru',
|
||||
appSeedColor: 0xFFE84D8A,
|
||||
@@ -108,7 +113,7 @@ class AppSettings {
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
autoDownloadFavorites: false,
|
||||
autoDownloadFavorites: true,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
@@ -123,6 +128,7 @@ class AppSettings {
|
||||
videoPlayerHalfVolume: false,
|
||||
videoPlayerLoop: false,
|
||||
videoPlayerCover: false,
|
||||
videoPlaybackPositions: {},
|
||||
hiddenPostKeys: [],
|
||||
diagnosticLogLines: [],
|
||||
lastFeedTags: [],
|
||||
@@ -165,6 +171,7 @@ class AppSettings {
|
||||
bool? videoPlayerHalfVolume,
|
||||
bool? videoPlayerLoop,
|
||||
bool? videoPlayerCover,
|
||||
Map<String, int>? videoPlaybackPositions,
|
||||
List<String>? hiddenPostKeys,
|
||||
List<String>? diagnosticLogLines,
|
||||
List<String>? lastFeedTags,
|
||||
@@ -176,8 +183,10 @@ class AppSettings {
|
||||
String? defaultRatingFilter,
|
||||
String? lastFeedRating,
|
||||
bool clearLastFeedRating = false,
|
||||
List<String>? favoriteArtists,
|
||||
}) {
|
||||
return AppSettings(
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
@@ -212,6 +221,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,
|
||||
@@ -227,6 +238,7 @@ class AppSettings {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'favoriteArtists': favoriteArtists,
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
@@ -256,6 +268,7 @@ class AppSettings {
|
||||
'videoPlayerHalfVolume': videoPlayerHalfVolume,
|
||||
'videoPlayerLoop': videoPlayerLoop,
|
||||
'videoPlayerCover': videoPlayerCover,
|
||||
'videoPlaybackPositions': videoPlaybackPositions,
|
||||
'hiddenPostKeys': hiddenPostKeys,
|
||||
'diagnosticLogLines': diagnosticLogLines,
|
||||
'lastFeedTags': lastFeedTags,
|
||||
@@ -334,6 +347,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,
|
||||
),
|
||||
@@ -354,6 +371,9 @@ class AppSettings {
|
||||
lastFeedScrollOffset:
|
||||
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
||||
defaults.lastFeedScrollOffset,
|
||||
favoriteArtists: List<String>.from(
|
||||
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
|
||||
),
|
||||
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
@@ -469,6 +489,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);
|
||||
|
||||
@@ -11,9 +11,9 @@ class UpdateService {
|
||||
UpdateService(this._dio, this._settingsService);
|
||||
|
||||
static const latestReleaseUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
|
||||
static const releasesUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||
|
||||
final Dio _dio;
|
||||
final SettingsService _settingsService;
|
||||
@@ -100,6 +100,7 @@ class UpdateService {
|
||||
String? apkUrl;
|
||||
String? installerUrl;
|
||||
String? portableUrl;
|
||||
String? linuxTarGzUrl;
|
||||
for (final item in assets.whereType<Map>()) {
|
||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||
final url = (item['browser_download_url'] ?? '').toString();
|
||||
@@ -107,6 +108,9 @@ class UpdateService {
|
||||
if (name.endsWith('.apk')) apkUrl = url;
|
||||
if (name.endsWith('.exe')) installerUrl = url;
|
||||
if (name.endsWith('.zip')) portableUrl = url;
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
linuxTarGzUrl = url;
|
||||
}
|
||||
}
|
||||
return AppUpdateInfo(
|
||||
version: _versionFromTag(tag),
|
||||
@@ -118,6 +122,7 @@ class UpdateService {
|
||||
apkUrl: apkUrl,
|
||||
windowsInstallerUrl: installerUrl,
|
||||
portableZipUrl: portableUrl,
|
||||
linuxTarGzUrl: linuxTarGzUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +130,9 @@ class UpdateService {
|
||||
if (Platform.isAndroid) {
|
||||
return info.apkUrl;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return info.linuxTarGzUrl ?? info.portableZipUrl;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
||||
}
|
||||
@@ -137,6 +145,7 @@ class UpdateService {
|
||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||
if (fromUrl.contains('.')) return fromUrl;
|
||||
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
|
||||
if (Platform.isLinux) return 'Lunaris-v${info.version}-linux-x64.tar.gz';
|
||||
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
|
||||
return 'LunarisPortable-v${info.version}.zip';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,10 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
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 '../../settings/presentation/settings_controller.dart';
|
||||
import 'artist_posts_screen.dart';
|
||||
|
||||
final artistProviderConfigsProvider =
|
||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||
@@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
Object? _error;
|
||||
List<ArtistProfile> _artists = const [];
|
||||
|
||||
String? _selectedFavoriteArtistKey;
|
||||
|
||||
Future<void> _toggleFavoriteArtist(
|
||||
AppSettings settings,
|
||||
ArtistProfile artist,
|
||||
) async {
|
||||
final current = List<String>.from(settings.favoriteArtists);
|
||||
final item = _FavoriteArtistItem(
|
||||
id: artist.id,
|
||||
service: artist.service,
|
||||
providerId: artist.providerId,
|
||||
name: artist.displayName,
|
||||
avatarUrl: artist.avatarUrl,
|
||||
);
|
||||
final key = item.key;
|
||||
final index = current.indexWhere((e) {
|
||||
try {
|
||||
final parsed =
|
||||
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
|
||||
return parsed.key == key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (index >= 0) {
|
||||
current.removeAt(index);
|
||||
} else {
|
||||
current.insert(0, jsonEncode(item.toJson()));
|
||||
}
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(favoriteArtists: current),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -55,6 +93,27 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final configs = ref.watch(artistProviderConfigsProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteItems = settings.favoriteArtists
|
||||
.map((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<_FavoriteArtistItem>()
|
||||
.toList();
|
||||
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||
final selectedFav = favoriteItems.firstWhere(
|
||||
(e) => e.key == _selectedFavoriteArtistKey,
|
||||
orElse: () => favoriteItems.isNotEmpty
|
||||
? favoriteItems.first
|
||||
: const _FavoriteArtistItem.empty(),
|
||||
);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
@@ -71,7 +130,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
if (items.isEmpty) {
|
||||
return const EmptyView(
|
||||
title: 'No artist providers',
|
||||
message: 'Enable Kemono or Coomer in Providers.',
|
||||
message: 'Enable an artist provider (such as Pawchive) in Providers.',
|
||||
);
|
||||
}
|
||||
_providerId ??= items.first.id;
|
||||
@@ -91,6 +150,32 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
},
|
||||
onSearch: () => _refresh(items),
|
||||
),
|
||||
_FavoriteArtistsSection(
|
||||
favorites: favoriteItems,
|
||||
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||
onSelect: (fav) =>
|
||||
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||
onOpenArtist: (fav) => context.push(
|
||||
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||
),
|
||||
onRemoveFavorite: (fav) async {
|
||||
final updated = List<String>.from(settings.favoriteArtists)
|
||||
..removeWhere((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
fav.key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(
|
||||
settings.copyWith(favoriteArtists: updated));
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? ErrorView(
|
||||
@@ -129,8 +214,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
return const _ArtistSkeletonCard();
|
||||
}
|
||||
final artist = _artists[index];
|
||||
final isFav = favoriteKeys.contains(
|
||||
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||
return _ArtistCard(
|
||||
artist: artist,
|
||||
isFavorite: isFav,
|
||||
onToggleFavorite: () =>
|
||||
_toggleFavoriteArtist(settings, artist),
|
||||
onTap: () => context.push(
|
||||
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
||||
),
|
||||
@@ -276,10 +366,17 @@ class _ArtistsHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ArtistCard extends StatelessWidget {
|
||||
const _ArtistCard({required this.artist, required this.onTap});
|
||||
const _ArtistCard({
|
||||
required this.artist,
|
||||
required this.onTap,
|
||||
required this.isFavorite,
|
||||
required this.onToggleFavorite,
|
||||
});
|
||||
|
||||
final ArtistProfile artist;
|
||||
final VoidCallback onTap;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -334,7 +431,15 @@ class _ArtistCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: isFavorite ? 'В избранном' : 'В избранное',
|
||||
onPressed: onToggleFavorite,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||
color: isFavorite ? Colors.amber : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
@@ -344,6 +449,359 @@ class _ArtistCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistItem {
|
||||
const _FavoriteArtistItem({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.providerId,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
const _FavoriteArtistItem.empty()
|
||||
: id = '',
|
||||
service = '',
|
||||
providerId = '',
|
||||
name = '',
|
||||
avatarUrl = null;
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String providerId;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
bool get isEmpty => id.isEmpty;
|
||||
String get key => '$providerId:$service:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'providerId': providerId,
|
||||
'name': name,
|
||||
'avatarUrl': avatarUrl,
|
||||
};
|
||||
|
||||
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
|
||||
_FavoriteArtistItem(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
providerId: (json['providerId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _FavoriteArtistsSection extends ConsumerWidget {
|
||||
const _FavoriteArtistsSection({
|
||||
required this.favorites,
|
||||
required this.selectedKey,
|
||||
required this.onSelect,
|
||||
required this.onOpenArtist,
|
||||
required this.onRemoveFavorite,
|
||||
});
|
||||
|
||||
final List<_FavoriteArtistItem> favorites;
|
||||
final String? selectedKey;
|
||||
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (favorites.isEmpty) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые и смотреть его медиа здесь.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selected = favorites.firstWhere(
|
||||
(f) => f.key == selectedKey,
|
||||
orElse: () => favorites.first,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Любимые авторы (${favorites.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => onOpenArtist(selected),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Все работы', style: TextStyle(fontSize: 12)),
|
||||
SizedBox(width: 2),
|
||||
Icon(Icons.arrow_forward_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 84,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: favorites.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final fav = favorites[index];
|
||||
final isSelected = fav.key == selected.key;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => onSelect(fav),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: ClipOval(
|
||||
child: fav.avatarUrl != null &&
|
||||
fav.avatarUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: fav.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const CircleAvatar(
|
||||
child:
|
||||
Icon(Icons.person_rounded, size: 20),
|
||||
),
|
||||
errorWidget: (_, __, ___) =>
|
||||
const CircleAvatar(
|
||||
child:
|
||||
Icon(Icons.person_rounded, size: 20),
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
fav.name.isNotEmpty
|
||||
? fav.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Text(
|
||||
fav.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FavoriteArtistMediaStrip(artist: selected),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistMediaStrip extends ConsumerWidget {
|
||||
const _FavoriteArtistMediaStrip({required this.artist});
|
||||
|
||||
final _FavoriteArtistItem artist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: artist.providerId,
|
||||
service: artist.service,
|
||||
artistId: artist.id,
|
||||
artistName: artist.name,
|
||||
);
|
||||
final asyncPosts = ref.watch(artistPostsProvider(query));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
||||
child: asyncPosts.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (posts) {
|
||||
if (posts.isEmpty) {
|
||||
return const SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: Text('Нет доступных фото или видео',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: posts.length.clamp(0, 15),
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final post = posts[index];
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: post.previewUrl.isNotEmpty
|
||||
? post.previewUrl
|
||||
: post.sampleUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_rounded,
|
||||
size: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded,
|
||||
color: Colors.white, size: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({required this.artist});
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -11,11 +11,19 @@ import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import 'favorites_controller.dart';
|
||||
|
||||
class FavoritesScreen extends ConsumerWidget {
|
||||
class FavoritesScreen extends ConsumerStatefulWidget {
|
||||
const FavoritesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<FavoritesScreen> createState() => _FavoritesScreenState();
|
||||
}
|
||||
|
||||
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
int _tabIndex = 0;
|
||||
String? _selectedArtist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(favoritesControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
@@ -33,28 +41,156 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => data.posts.isEmpty
|
||||
? const EmptyView(title: 'No favorites yet')
|
||||
: PostMasonryGrid(
|
||||
posts: data.posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
||||
downloadedKeys: downloaded.keys.toSet(),
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: Icon(Icons.favorite_rounded),
|
||||
label: Text('All'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: Icon(Icons.offline_pin_rounded),
|
||||
label: Text('Offline'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
icon: Icon(Icons.groups_rounded),
|
||||
label: Text('Artists'),
|
||||
),
|
||||
],
|
||||
selected: {_tabIndex},
|
||||
onSelectionChanged: (value) {
|
||||
setState(() {
|
||||
_tabIndex = value.first;
|
||||
_selectedArtist = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (_tabIndex) {
|
||||
1 => _postsGrid(
|
||||
data.posts
|
||||
.where((post) =>
|
||||
downloaded.containsKey(post.cacheKey))
|
||||
.toList(),
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
2 => _artistAlbums(
|
||||
data.posts,
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
_ => _postsGrid(
|
||||
data.posts,
|
||||
settings,
|
||||
downloaded.keys.toSet(),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _postsGrid(
|
||||
List<Post> posts,
|
||||
AppSettings settings,
|
||||
Set<String> downloadedKeys,
|
||||
) {
|
||||
if (posts.isEmpty) return const EmptyView(title: 'Nothing here yet');
|
||||
return PostMasonryGrid(
|
||||
posts: posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: posts.map((post) => post.cacheKey).toSet(),
|
||||
downloadedKeys: downloadedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistAlbums(
|
||||
List<Post> posts,
|
||||
AppSettings settings,
|
||||
Set<String> downloadedKeys,
|
||||
) {
|
||||
final groups = favoriteArtistAlbums(posts);
|
||||
final selected = _selectedArtist;
|
||||
if (selected != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _selectedArtist = null),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
label: Text(selected),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _postsGrid(
|
||||
groups[selected] ?? const [],
|
||||
settings,
|
||||
downloadedKeys,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
children: [
|
||||
for (final entry in groups.entries)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_rounded),
|
||||
title: Text(entry.key),
|
||||
subtitle: Text('${entry.value.length} posts'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => setState(() => _selectedArtist = entry.key),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<Post>> favoriteArtistAlbums(List<Post> posts) {
|
||||
final groups = <String, List<Post>>{};
|
||||
for (final post in posts) {
|
||||
final artists = post.tagGroups['artist'] ??
|
||||
post.tags.where((tag) => tag.startsWith('artist:')).toList();
|
||||
final names = artists.isEmpty
|
||||
? const ['Unknown artist']
|
||||
: artists.map((tag) => tag.replaceFirst('artist:', '')).toList();
|
||||
for (final name in names) {
|
||||
groups.putIfAbsent(name, () => []).add(post);
|
||||
}
|
||||
}
|
||||
return Map.fromEntries(
|
||||
groups.entries.toList()
|
||||
..sort((a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,22 @@ final feedControllerProvider =
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
Timer? _providerDebounce;
|
||||
Timer? _suggestionDebounce;
|
||||
StreamSubscription<ProviderSoftRetryResult>? _softRetrySubscription;
|
||||
int _loadRequestId = 0;
|
||||
int _suggestionRequestId = 0;
|
||||
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
ref.onDispose(() => _providerDebounce?.cancel());
|
||||
ref.onDispose(() {
|
||||
_providerDebounce?.cancel();
|
||||
_suggestionDebounce?.cancel();
|
||||
_softRetrySubscription?.cancel();
|
||||
});
|
||||
_softRetrySubscription ??= ref
|
||||
.read(providerManagerProvider)
|
||||
.softRetryResults
|
||||
.listen(_applySoftRetryResult);
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
@@ -42,7 +53,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
state = AsyncData(current.copyWith(
|
||||
posts: posts,
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
clearError: true,
|
||||
providerStatusMessage: await _providerStatusMessage(current),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
state = AsyncData(current.copyWith(
|
||||
posts: posts,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
providerStatusMessage: await _providerStatusMessage(current),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
@@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
final posts = <Post>[];
|
||||
const maxEmptyPageSkips = 3;
|
||||
var attempts = 0;
|
||||
while (posts.isEmpty && attempts < maxEmptyPageSkips) {
|
||||
attempts++;
|
||||
posts.addAll(await _load(refresh: false, current: current));
|
||||
if (requestId != _loadRequestId) return;
|
||||
}
|
||||
if (requestId != _loadRequestId) return;
|
||||
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = posts
|
||||
.where((post) => existingKeys.add(post.cacheKey))
|
||||
.toList(growable: false);
|
||||
final emptyPageStreak =
|
||||
newPosts.isEmpty ? current.emptyPageStreak + attempts : 0;
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...newPosts],
|
||||
isLoadingMore: false,
|
||||
hasMore: posts.isNotEmpty,
|
||||
hasMore: newPosts.isNotEmpty || emptyPageStreak < maxEmptyPageSkips,
|
||||
emptyPageStreak: emptyPageStreak,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -78,7 +111,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final tags = ref.read(searchServiceProvider).parseTags(query);
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []),
|
||||
current.copyWith(
|
||||
selectedTags: tags,
|
||||
posts: [],
|
||||
tagSuggestions: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
await saveSession(scrollOffset: 0);
|
||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
||||
@@ -89,31 +128,42 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> updateTagSuggestions(String query) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
if (query.trim().isEmpty) {
|
||||
final lastToken =
|
||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||
final requestId = ++_suggestionRequestId;
|
||||
_suggestionDebounce?.cancel();
|
||||
if (lastToken.trim().isEmpty) {
|
||||
state = AsyncData(current.copyWith(tagSuggestions: []));
|
||||
return;
|
||||
}
|
||||
final lastToken = query.trim().split(RegExp(r'\s+')).last;
|
||||
final result = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
final normalizedToken = lastToken.trim().toLowerCase();
|
||||
final suggestions = result is Success<List<TagSuggestion>>
|
||||
? result.data
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
normalizedToken,
|
||||
))
|
||||
.toList(growable: false)
|
||||
: const <TagSuggestion>[];
|
||||
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
|
||||
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
|
||||
final result = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
if (requestId != _suggestionRequestId) return;
|
||||
final normalizedToken = lastToken.trim().toLowerCase();
|
||||
final suggestions = result is Success<List<TagSuggestion>>
|
||||
? result.data
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
normalizedToken,
|
||||
))
|
||||
.toList(growable: false)
|
||||
: const <TagSuggestion>[];
|
||||
final latest = state.value ?? current;
|
||||
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final enabledIds = current.providers.map((provider) => provider.id).toSet();
|
||||
final selected = providerIds.where(enabledIds.contains).toList();
|
||||
state =
|
||||
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
|
||||
state = AsyncData(current.copyWith(
|
||||
selectedProviderIds: selected,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
@@ -129,7 +179,12 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(topPeriodFilter: period, posts: []));
|
||||
state = AsyncData(current.copyWith(
|
||||
topPeriodFilter: period,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(defaultTopPeriodFilter: period.name),
|
||||
@@ -142,7 +197,12 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
ratingFilter: rating,
|
||||
clearRating: rating == null,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
await saveSession(scrollOffset: 0);
|
||||
@@ -157,6 +217,8 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
tagSuggestions: [],
|
||||
clearRating: true,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
final settings = await _settings();
|
||||
@@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
return posts;
|
||||
}
|
||||
|
||||
void _applySoftRetryResult(ProviderSoftRetryResult result) {
|
||||
final current = state.value;
|
||||
if (current == null || result.page != 0) return;
|
||||
if (!_sameTags(current.selectedTags, result.tags)) return;
|
||||
if (current.ratingFilter != result.rating) return;
|
||||
if (current.topPeriodFilter != result.topPeriod) return;
|
||||
if (current.selectedProviderIds.isNotEmpty &&
|
||||
!current.selectedProviderIds.contains(result.providerId)) {
|
||||
return;
|
||||
}
|
||||
final seen = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = result.posts
|
||||
.where((post) => seen.add(post.cacheKey))
|
||||
.toList(growable: false);
|
||||
if (newPosts.isEmpty) return;
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...newPosts]..sort(
|
||||
(a, b) => _mixKey(a).compareTo(_mixKey(b)),
|
||||
),
|
||||
providerStatusMessage: 'Retry added ${newPosts.length} posts',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _sameTags(List<String> left, List<String> right) {
|
||||
if (left.length != right.length) return false;
|
||||
for (var i = 0; i < left.length; i++) {
|
||||
if (left[i] != right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String?> _providerStatusMessage(FeedState current) async {
|
||||
final result = await ref.read(providerRepositoryProvider).getDiagnostics();
|
||||
if (result is! Success<List<ProviderDiagnostics>>) return null;
|
||||
final selected = current.selectedProviderIds.isEmpty
|
||||
? current.providers.map((provider) => provider.id).toSet()
|
||||
: current.selectedProviderIds.toSet();
|
||||
final failed = result.data
|
||||
.where((item) =>
|
||||
selected.contains(item.providerId) &&
|
||||
item.lastErrorMessage != null &&
|
||||
item.lastErrorMessage!.isNotEmpty)
|
||||
.map((item) => item.providerId)
|
||||
.toSet();
|
||||
if (failed.isEmpty) return null;
|
||||
return 'Retrying ${failed.length} provider${failed.length == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
int _mixKey(Post post) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final code in post.cacheKey.codeUnits) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -28,20 +29,30 @@ class FeedScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<FeedScreen> createState() => _FeedScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _scrollController = ScrollController();
|
||||
final Set<String> _selectedKeys = {};
|
||||
bool _selectionMode = false;
|
||||
bool _showScrollToTop = false;
|
||||
String? _appliedInitialQuery;
|
||||
Timer? _scrollSaveDebounce;
|
||||
double _lastKnownScrollOffset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.extentAfter < 800) {
|
||||
ref.read(feedControllerProvider.notifier).loadNextPage();
|
||||
}
|
||||
_lastKnownScrollOffset = _scrollController.offset;
|
||||
final shouldShow =
|
||||
_scrollController.hasClients && _scrollController.offset > 1200;
|
||||
if (shouldShow != _showScrollToTop) {
|
||||
setState(() => _showScrollToTop = shouldShow);
|
||||
}
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
|
||||
ref
|
||||
@@ -53,6 +64,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
|
||||
_lastKnownScrollOffset = settings.lastFeedScrollOffset;
|
||||
_scrollController.jumpTo(
|
||||
settings.lastFeedScrollOffset.clamp(
|
||||
0.0,
|
||||
@@ -80,11 +92,32 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
if (_scrollController.hasClients) {
|
||||
_lastKnownScrollOffset = _scrollController.offset;
|
||||
ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.saveSession(scrollOffset: _lastKnownScrollOffset);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final target = _lastKnownScrollOffset.clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
if ((_scrollController.offset - target).abs() > 24) {
|
||||
_scrollController.jumpTo(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final feed = ref.watch(feedControllerProvider);
|
||||
@@ -146,6 +179,19 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
child: AdaptiveScaffold(
|
||||
title: 'Feed',
|
||||
titleWidget: const _FeedTitle(),
|
||||
floatingActionButton: _showScrollToTop
|
||||
? FloatingActionButton.small(
|
||||
tooltip: 'Наверх',
|
||||
onPressed: () {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.arrow_upward_rounded),
|
||||
)
|
||||
: null,
|
||||
body: feed.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(
|
||||
@@ -162,6 +208,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
tagSuggestions: state.tagSuggestions,
|
||||
providers: state.providers,
|
||||
rating: state.ratingFilter,
|
||||
providerStatusMessage: state.providerStatusMessage,
|
||||
onSearchChanged: (query) => ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.updateTagSuggestions(query),
|
||||
@@ -173,6 +220,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
onRefresh: () =>
|
||||
ref.read(feedControllerProvider.notifier).refresh(),
|
||||
onClearFilters: _clearFilters,
|
||||
onRandom: () => _openRandom(state.posts),
|
||||
selectionMode: _selectionMode,
|
||||
onToggleSelectionMode: () {
|
||||
setState(() {
|
||||
@@ -228,6 +276,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
.read(feedControllerProvider.notifier)
|
||||
.refresh(),
|
||||
child: PostMasonryGrid(
|
||||
key: const PageStorageKey('feed_masonry_grid'),
|
||||
controller: _scrollController,
|
||||
posts: state.posts,
|
||||
columns: Responsive.columnsFor(
|
||||
@@ -247,7 +296,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
selectedKeys: _selectedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: state.posts,
|
||||
),
|
||||
),
|
||||
onPreview: (post) => _showPreview(context, post),
|
||||
onToggleSelected: (post) => _toggleSelected(post),
|
||||
@@ -296,6 +348,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
context.go(location);
|
||||
}
|
||||
|
||||
void _openRandom(List<Post> posts) {
|
||||
if (posts.isEmpty) return;
|
||||
final post = posts[Random().nextInt(posts.length)];
|
||||
context.push('/post/${post.providerId}/${post.id}', extra: post);
|
||||
}
|
||||
|
||||
Future<void> _applySearchQuery(String query) async {
|
||||
await ref.read(feedControllerProvider.notifier).search(query);
|
||||
if (_scrollController.hasClients) {
|
||||
|
||||
@@ -11,7 +11,9 @@ class FeedState {
|
||||
this.providers = const [],
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = true,
|
||||
this.emptyPageStreak = 0,
|
||||
this.error,
|
||||
this.providerStatusMessage,
|
||||
});
|
||||
|
||||
final List<Post> posts;
|
||||
@@ -23,7 +25,9 @@ class FeedState {
|
||||
final List<ContentProviderConfig> providers;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final int emptyPageStreak;
|
||||
final String? error;
|
||||
final String? providerStatusMessage;
|
||||
|
||||
FeedState copyWith({
|
||||
List<Post>? posts,
|
||||
@@ -36,8 +40,11 @@ class FeedState {
|
||||
List<ContentProviderConfig>? providers,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
int? emptyPageStreak,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
String? providerStatusMessage,
|
||||
bool clearProviderStatus = false,
|
||||
}) {
|
||||
return FeedState(
|
||||
posts: posts ?? this.posts,
|
||||
@@ -49,7 +56,11 @@ class FeedState {
|
||||
providers: providers ?? this.providers,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
emptyPageStreak: emptyPageStreak ?? this.emptyPageStreak,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
providerStatusMessage: clearProviderStatus
|
||||
? null
|
||||
: providerStatusMessage ?? this.providerStatusMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
this.onToggleSelectionMode,
|
||||
this.onRandom,
|
||||
this.providerStatusMessage,
|
||||
this.selectionMode = false,
|
||||
super.key,
|
||||
});
|
||||
@@ -43,6 +45,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
final VoidCallback? onToggleSelectionMode;
|
||||
final VoidCallback? onRandom;
|
||||
final String? providerStatusMessage;
|
||||
final bool selectionMode;
|
||||
|
||||
@override
|
||||
@@ -122,9 +126,24 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_HorizontalWheelScroller(
|
||||
height: 36,
|
||||
builder: (controller) => ListView(
|
||||
@@ -187,6 +206,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
_HorizontalWheelScroller(
|
||||
height: 40,
|
||||
builder: (controller) => ListView(
|
||||
@@ -219,6 +248,11 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
|
||||
@@ -3,17 +3,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
// Меняем FutureProvider на StreamProvider
|
||||
// Stream the fastest available post version first, then enrich it.
|
||||
final postDetailsControllerProvider =
|
||||
StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* {
|
||||
// Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
|
||||
final providerManager = ref.watch(providerManagerProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
final postRepository = ref.watch(postRepositoryProvider);
|
||||
|
||||
// Вспомогательная функция для загрузки тегов
|
||||
bool needsRealbooruDetails(Post post) {
|
||||
if (post.providerId != 'realbooru') return false;
|
||||
final preview = post.previewUrl.toLowerCase();
|
||||
final sample = post.sampleUrl.toLowerCase();
|
||||
return post.fileUrl.isEmpty ||
|
||||
post.fileUrl == post.previewUrl ||
|
||||
post.fileUrl == post.sampleUrl ||
|
||||
preview.contains('/thumbnails/') ||
|
||||
sample.contains('/thumbnails/');
|
||||
}
|
||||
|
||||
Future<Post?> enrich(Post? post) async {
|
||||
if (post == null) return null;
|
||||
if (needsRealbooruDetails(post)) {
|
||||
final remote = await providerManager.getPost(post.providerId, post.id);
|
||||
if (remote is Success<Post?> && remote.data != null) {
|
||||
await cacheService.cachePosts([remote.data!]);
|
||||
return remote.data!;
|
||||
}
|
||||
}
|
||||
final shouldEnrich = post.tagGroups.isEmpty ||
|
||||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
|
||||
if (!shouldEnrich) return post;
|
||||
@@ -26,26 +42,29 @@ final postDetailsControllerProvider =
|
||||
return post;
|
||||
}
|
||||
|
||||
// СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай)
|
||||
// Scenario 1: feed navigation already passed the post.
|
||||
if (args.initialPost != null) {
|
||||
yield args
|
||||
.initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится!
|
||||
if (!needsRealbooruDetails(args.initialPost!)) {
|
||||
yield args.initialPost;
|
||||
}
|
||||
final enriched = await enrich(args.initialPost);
|
||||
yield enriched; // Чуть позже отдаем версию с загруженными тегами
|
||||
yield enriched;
|
||||
return;
|
||||
}
|
||||
|
||||
// СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш
|
||||
// Scenario 2: direct navigation can use the local cache first.
|
||||
final cached =
|
||||
await postRepository.getCachedPost(args.postId, args.providerId);
|
||||
if (cached is Success<Post?> && cached.data != null) {
|
||||
yield cached.data; // Нашли в кэше — сразу показываем контент
|
||||
if (!needsRealbooruDetails(cached.data!)) {
|
||||
yield cached.data;
|
||||
}
|
||||
final enriched = await enrich(cached.data);
|
||||
yield enriched; // Догружаем теги на фоне
|
||||
yield enriched;
|
||||
return;
|
||||
}
|
||||
|
||||
// СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом
|
||||
// Scenario 3: fall back to the provider when nothing local is available.
|
||||
final remote = await providerManager.getPost(args.providerId, args.postId);
|
||||
if (remote is Success<Post?> && remote.data != null) {
|
||||
yield remote.data;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -39,12 +40,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
this.postsList,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -57,8 +60,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final feedPosts = postsList ??
|
||||
ref.watch(feedControllerProvider).value?.posts ??
|
||||
const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: strings.post,
|
||||
@@ -94,7 +98,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
return _MobilePostPager(
|
||||
posts: feedPosts,
|
||||
initialIndex: currentIndex,
|
||||
buildDetails: (context, post) => _buildMobileDetails(
|
||||
buildDetails: (context, post, mediaGestureLocked,
|
||||
onMediaGestureLockChanged) =>
|
||||
_buildMobileDetails(
|
||||
context,
|
||||
ref,
|
||||
post,
|
||||
@@ -102,6 +108,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
mediaGestureLocked,
|
||||
onMediaGestureLockChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -113,14 +121,20 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
}
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
|
||||
const _PreviousPostIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyA):
|
||||
const _PreviousPostIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowRight):
|
||||
const _NextPostIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyD):
|
||||
const _NextPostIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyF):
|
||||
const _ToggleFavoriteIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyC):
|
||||
@@ -186,17 +200,29 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 760),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 760,
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
mediaHeaders: ref
|
||||
.watch(postMediaHeadersProvider(post))
|
||||
.value ??
|
||||
const {},
|
||||
initialPosition: Duration(
|
||||
milliseconds: settings.videoPlaybackPositions[
|
||||
post.cacheKey] ??
|
||||
0,
|
||||
),
|
||||
initialLoop: settings.videoPlayerLoop,
|
||||
initialMuted: settings.videoPlayerMuted,
|
||||
initialCoverVideo: settings.videoPlayerCover,
|
||||
initialHalfVolume:
|
||||
settings.videoPlayerHalfVolume,
|
||||
onPlaybackSnapshot: (snapshot) =>
|
||||
_saveVideoSnapshot(ref, post, snapshot),
|
||||
onPlaybackPreferencesChanged: (snapshot) =>
|
||||
_saveVideoPreferences(ref, snapshot),
|
||||
),
|
||||
@@ -233,6 +259,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onOpenSource: () => _openSourcePage(ref, post),
|
||||
onCopy: () =>
|
||||
Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onSimilar: () => _openSimilar(context, ref, post),
|
||||
@@ -284,11 +311,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Set<String> favoriteKeys,
|
||||
MediaQualityMode qualityMode,
|
||||
AppStrings strings,
|
||||
bool mediaGestureLocked,
|
||||
ValueChanged<bool>? onMediaGestureLockChanged,
|
||||
) {
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onVerticalDragEnd: (details) {
|
||||
if (mediaGestureLocked) return;
|
||||
if ((details.primaryVelocity ?? 0) > 900) _close(context);
|
||||
},
|
||||
child: ListView(
|
||||
@@ -302,22 +332,30 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
? null
|
||||
: () =>
|
||||
_showMobileQuickActions(context, ref, post, favoriteKeys),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
|
||||
),
|
||||
child: Center(
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
initialLoop: settings.videoPlayerLoop,
|
||||
initialMuted: settings.videoPlayerMuted,
|
||||
initialCoverVideo: settings.videoPlayerCover,
|
||||
initialHalfVolume: settings.videoPlayerHalfVolume,
|
||||
onPlaybackPreferencesChanged: (snapshot) =>
|
||||
_saveVideoPreferences(ref, snapshot),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: MediaQuery.sizeOf(context).height * 0.62,
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
mediaHeaders:
|
||||
ref.watch(postMediaHeadersProvider(post)).value ?? const {},
|
||||
initialPosition: Duration(
|
||||
milliseconds:
|
||||
settings.videoPlaybackPositions[post.cacheKey] ?? 0,
|
||||
),
|
||||
initialLoop: settings.videoPlayerLoop,
|
||||
initialMuted: settings.videoPlayerMuted,
|
||||
initialCoverVideo: settings.videoPlayerCover,
|
||||
initialHalfVolume: settings.videoPlayerHalfVolume,
|
||||
onPlaybackSnapshot: (snapshot) =>
|
||||
_saveVideoSnapshot(ref, post, snapshot),
|
||||
onPlaybackPreferencesChanged: (snapshot) =>
|
||||
_saveVideoPreferences(ref, snapshot),
|
||||
onMediaGestureLockChanged: (locked) {
|
||||
onMediaGestureLockChanged?.call(locked);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -331,6 +369,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onOpenSource: () => _openSourcePage(ref, post),
|
||||
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onSimilar: () => _openSimilar(context, ref, post),
|
||||
onHide: () => _hidePost(context, ref, post),
|
||||
@@ -545,6 +584,18 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
|
||||
}
|
||||
|
||||
Future<void> _openSourcePage(WidgetRef ref, Post post) async {
|
||||
final source = post.source?.trim();
|
||||
String? url = source != null && source.isNotEmpty ? source : null;
|
||||
if (url == null) {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).getPostPageUrl(post);
|
||||
if (result is Success<String?>) url = result.data;
|
||||
}
|
||||
if (url == null || url.isEmpty) return;
|
||||
await launchUrl(Uri.parse(url));
|
||||
}
|
||||
|
||||
void _replacePost(BuildContext context, Post post) {
|
||||
context.replace('/post/${post.providerId}/${post.id}', extra: post);
|
||||
}
|
||||
@@ -580,6 +631,18 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
}
|
||||
|
||||
Future<void> _saveVideoSnapshot(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
VideoPlaybackSnapshot snapshot,
|
||||
) async {
|
||||
await ref.read(settingsServiceProvider).saveVideoPlaybackPosition(
|
||||
post.cacheKey,
|
||||
snapshot.position.inMilliseconds,
|
||||
);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobilePostPager extends StatefulWidget {
|
||||
@@ -591,19 +654,25 @@ class _MobilePostPager extends StatefulWidget {
|
||||
|
||||
final List<Post> posts;
|
||||
final int initialIndex;
|
||||
final Widget Function(BuildContext context, Post post) buildDetails;
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Post post,
|
||||
bool mediaGestureLocked,
|
||||
ValueChanged<bool> onMediaGestureLockChanged,
|
||||
) buildDetails;
|
||||
|
||||
@override
|
||||
State<_MobilePostPager> createState() => _MobilePostPagerState();
|
||||
}
|
||||
|
||||
class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
late final PageController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = PageController(initialPage: widget.initialIndex);
|
||||
class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
late final PageController _controller;
|
||||
bool _mediaGestureLocked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = PageController(initialPage: widget.initialIndex);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_prefetchAround(widget.initialIndex);
|
||||
});
|
||||
@@ -613,11 +682,11 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
|
||||
if (oldWidget.initialIndex != widget.initialIndex &&
|
||||
widget.initialIndex >= 0 &&
|
||||
widget.initialIndex < widget.posts.length) {
|
||||
if (_controller.hasClients) {
|
||||
_controller.jumpToPage(widget.initialIndex);
|
||||
if (oldWidget.initialIndex != widget.initialIndex &&
|
||||
widget.initialIndex >= 0 &&
|
||||
widget.initialIndex < widget.posts.length) {
|
||||
if (_controller.hasClients) {
|
||||
_controller.jumpToPage(widget.initialIndex);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_prefetchAround(widget.initialIndex);
|
||||
@@ -633,21 +702,59 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return _KeepAlivePostPage(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(widget.posts[index].cacheKey),
|
||||
child: widget.buildDetails(context, widget.posts[index]),
|
||||
),
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
physics: _mediaGestureLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const PageScrollPhysics(),
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final initialPost = widget.posts[index];
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final args = PostDetailsArgs(
|
||||
providerId: initialPost.providerId,
|
||||
postId: initialPost.id,
|
||||
initialPost: initialPost,
|
||||
);
|
||||
final post = ref.watch(postDetailsControllerProvider(args));
|
||||
return post.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (resolvedPost) => _KeepAlivePostPage(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey((resolvedPost ?? initialPost).cacheKey),
|
||||
child: widget.buildDetails(
|
||||
context,
|
||||
resolvedPost ?? initialPost,
|
||||
_mediaGestureLocked,
|
||||
_setMediaGestureLocked,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _setMediaGestureLocked(bool locked) {
|
||||
if (_mediaGestureLocked == locked) return;
|
||||
setState(() => _mediaGestureLocked = locked);
|
||||
}
|
||||
|
||||
void _prefetchAround(int index) {
|
||||
@@ -670,19 +777,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
return const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
post.fileUrl.contains('gelbooru.com') ||
|
||||
post.sampleUrl.contains('gelbooru.com') ||
|
||||
post.previewUrl.contains('gelbooru.com'))
|
||||
'Referer': 'https://gelbooru.com/',
|
||||
if (post.providerName.toLowerCase().contains('rule34') ||
|
||||
post.fileUrl.contains('rule34') ||
|
||||
post.sampleUrl.contains('rule34') ||
|
||||
post.previewUrl.contains('rule34'))
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class PostActionBar extends StatelessWidget {
|
||||
required this.onOpen,
|
||||
required this.onCopy,
|
||||
required this.isFavorite,
|
||||
this.onOpenSource,
|
||||
this.onDownload,
|
||||
this.onDeleteLocalFile,
|
||||
this.downloaded = false,
|
||||
@@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget {
|
||||
final VoidCallback onCollection;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onCopy;
|
||||
final VoidCallback? onOpenSource;
|
||||
final bool isFavorite;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onDeleteLocalFile;
|
||||
@@ -58,6 +60,12 @@ class PostActionBar extends StatelessWidget {
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
),
|
||||
if (onOpenSource != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.openSource,
|
||||
onPressed: onOpenSource,
|
||||
icon: const Icon(Icons.article_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.copyLink,
|
||||
onPressed: onCopy,
|
||||
@@ -95,6 +103,7 @@ class PostActionLabels {
|
||||
this.collection = 'Collection',
|
||||
this.similar = 'Similar',
|
||||
this.openOriginal = 'Open original',
|
||||
this.openSource = 'Open source/page',
|
||||
this.copyLink = 'Copy link',
|
||||
this.download = 'Download',
|
||||
this.deleteLocalFile = 'Delete local file',
|
||||
@@ -106,6 +115,7 @@ class PostActionLabels {
|
||||
final String collection;
|
||||
final String similar;
|
||||
final String openOriginal;
|
||||
final String openSource;
|
||||
final String copyLink;
|
||||
final String download;
|
||||
final String deleteLocalFile;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../app/app.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/tag_chip.dart';
|
||||
import '../../../feed/presentation/feed_controller.dart';
|
||||
import '../../../settings/presentation/settings_controller.dart';
|
||||
|
||||
class PostTagsPanel extends StatefulWidget {
|
||||
class PostTagsPanel extends ConsumerStatefulWidget {
|
||||
const PostTagsPanel({required this.post, super.key});
|
||||
|
||||
final Post post;
|
||||
|
||||
@override
|
||||
State<PostTagsPanel> createState() => _PostTagsPanelState();
|
||||
ConsumerState<PostTagsPanel> createState() => _PostTagsPanelState();
|
||||
}
|
||||
|
||||
class _PostTagsPanelState extends State<PostTagsPanel> {
|
||||
class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
|
||||
final _expandedGroups = <String>{};
|
||||
|
||||
@override
|
||||
@@ -26,6 +31,7 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
|
||||
for (final entry in groups.entries)
|
||||
_TagGroupBlock(
|
||||
label: _label(entry.key),
|
||||
group: entry.key,
|
||||
tags: entry.value,
|
||||
expanded: _expandedGroups.contains(entry.key),
|
||||
onToggleExpanded: () {
|
||||
@@ -36,11 +42,183 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
|
||||
});
|
||||
},
|
||||
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
|
||||
onLongPress: (tag) =>
|
||||
_showTagActionSheet(context, tag: tag, group: entry.key),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showTagActionSheet(
|
||||
BuildContext context, {
|
||||
required String tag,
|
||||
required String group,
|
||||
}) {
|
||||
final currentTags =
|
||||
ref.read(feedControllerProvider).value?.selectedTags ?? const [];
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isBlacklisted = settings.blacklistedTags.contains(tag);
|
||||
final isArtist = group == 'artist';
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (modalContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_label(group),
|
||||
style:
|
||||
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tag,
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.search_rounded),
|
||||
title: const Text('Искать только этот тег'),
|
||||
onTap: () {
|
||||
Navigator.pop(modalContext);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline_rounded),
|
||||
title: const Text('Добавить к поиску (+тег)'),
|
||||
subtitle: currentTags.isNotEmpty
|
||||
? Text('Текущие: ${currentTags.join(', ')}')
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(modalContext);
|
||||
final nextQuery = currentTags.contains(tag)
|
||||
? currentTags.join(' ')
|
||||
: [...currentTags, tag].join(' ');
|
||||
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.remove_circle_outline_rounded),
|
||||
title: const Text('Исключить из поиска (-тег)'),
|
||||
onTap: () {
|
||||
Navigator.pop(modalContext);
|
||||
final filtered = currentTags
|
||||
.where((t) => t != tag && t != '-$tag')
|
||||
.toList();
|
||||
final nextQuery = [...filtered, '-$tag'].join(' ');
|
||||
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isBlacklisted
|
||||
? Icons.block_flipped
|
||||
: Icons.block_rounded,
|
||||
color: isBlacklisted
|
||||
? null
|
||||
: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
isBlacklisted
|
||||
? 'Удалить из чёрного списка'
|
||||
: 'В чёрный список (Blacklist)',
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(modalContext);
|
||||
final updatedList = isBlacklisted
|
||||
? settings.blacklistedTags
|
||||
.where((t) => t != tag)
|
||||
.toList()
|
||||
: [...settings.blacklistedTags, tag];
|
||||
final nextSettings =
|
||||
settings.copyWith(blacklistedTags: updatedList);
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(nextSettings);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isBlacklisted
|
||||
? 'Тег "$tag" удалён из чёрного списка'
|
||||
: 'Тег "$tag" добавлен в чёрный список',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy_rounded),
|
||||
title: const Text('Скопировать тег'),
|
||||
onTap: () {
|
||||
Navigator.pop(modalContext);
|
||||
Clipboard.setData(ClipboardData(text: tag));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Тег "$tag" скопирован в буфер'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isArtist)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette_rounded),
|
||||
title: const Text('Все работы этого автора'),
|
||||
onTap: () {
|
||||
Navigator.pop(modalContext);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<String>> _groups(Post post) {
|
||||
if (post.tagGroups.isNotEmpty) {
|
||||
final ordered = <String, List<String>>{};
|
||||
@@ -81,19 +259,23 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
|
||||
class _TagGroupBlock extends StatelessWidget {
|
||||
const _TagGroupBlock({
|
||||
required this.label,
|
||||
required this.group,
|
||||
required this.tags,
|
||||
required this.expanded,
|
||||
required this.onToggleExpanded,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
static const _collapsedLimit = 36;
|
||||
|
||||
final String label;
|
||||
final String group;
|
||||
final List<String> tags;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggleExpanded;
|
||||
final ValueChanged<String> onTap;
|
||||
final ValueChanged<String> onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -126,6 +308,7 @@ class _TagGroupBlock extends StatelessWidget {
|
||||
TagChip(
|
||||
tag: tag,
|
||||
onTap: () => onTap(tag),
|
||||
onLongPress: () => onLongPress(tag),
|
||||
),
|
||||
if (tags.length > _collapsedLimit)
|
||||
ActionChip(
|
||||
|
||||
@@ -89,7 +89,9 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
DropdownMenuItem(
|
||||
value: 'rule34', child: Text('rule34-compatible')),
|
||||
DropdownMenuItem(
|
||||
value: 'realbooru', child: Text('realbooru-compatible')),
|
||||
value: 'paheal', child: Text('Rule34 Paheal HTML')),
|
||||
DropdownMenuItem(
|
||||
value: 'realbooru_html', child: Text('realbooru HTML')),
|
||||
DropdownMenuItem(
|
||||
value: 'danbooru', child: Text('danbooru-compatible')),
|
||||
DropdownMenuItem(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
@@ -8,8 +10,12 @@ final searchControllerProvider =
|
||||
AsyncNotifierProvider<SearchController, SearchState>(SearchController.new);
|
||||
|
||||
class SearchController extends AsyncNotifier<SearchState> {
|
||||
Timer? _suggestionDebounce;
|
||||
int _suggestionRequestId = 0;
|
||||
|
||||
@override
|
||||
Future<SearchState> build() async {
|
||||
ref.onDispose(() => _suggestionDebounce?.cancel());
|
||||
final recent = await _recent();
|
||||
return SearchState(recent: recent);
|
||||
}
|
||||
@@ -17,18 +23,42 @@ class SearchController extends AsyncNotifier<SearchState> {
|
||||
Future<void> updateQuery(String query) async {
|
||||
final lastToken =
|
||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||
final suggestionsResult = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
|
||||
? suggestionsResult.data
|
||||
: <TagSuggestion>[];
|
||||
final requestId = ++_suggestionRequestId;
|
||||
_suggestionDebounce?.cancel();
|
||||
if (lastToken.isEmpty) {
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(
|
||||
query: query,
|
||||
suggestions: const [],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(
|
||||
query: query,
|
||||
suggestions: suggestions,
|
||||
),
|
||||
);
|
||||
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
|
||||
final suggestionsResult = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
if (requestId != _suggestionRequestId) return;
|
||||
final normalizedToken = lastToken.trim().toLowerCase();
|
||||
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
|
||||
? suggestionsResult.data
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
normalizedToken,
|
||||
))
|
||||
.toList(growable: false)
|
||||
: <TagSuggestion>[];
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(
|
||||
query: query,
|
||||
suggestions: suggestions,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
|
||||
@@ -2,24 +2,45 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class RecentSearches extends StatelessWidget {
|
||||
class RecentSearches extends StatefulWidget {
|
||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
||||
|
||||
final List<SearchHistory> items;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
State<RecentSearches> createState() => _RecentSearchesState();
|
||||
}
|
||||
|
||||
class _RecentSearchesState extends State<RecentSearches> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const Text('No recent searches');
|
||||
if (widget.items.isEmpty) return const Text('No recent searches');
|
||||
final visibleItems =
|
||||
_expanded ? widget.items : widget.items.take(30).toList();
|
||||
return Column(
|
||||
children: [
|
||||
for (final item in items)
|
||||
for (final item in visibleItems)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(item.query),
|
||||
subtitle:
|
||||
Text('${item.tags.join(' ')} · ${item.resultCount} results'),
|
||||
onTap: () => onTap(item.query),
|
||||
Text('${item.tags.join(' ')} - ${item.resultCount} results'),
|
||||
onTap: () => widget.onTap(item.query),
|
||||
),
|
||||
if (widget.items.length > 30)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(
|
||||
_expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded,
|
||||
),
|
||||
label: Text(
|
||||
_expanded
|
||||
? 'Show latest 30'
|
||||
: 'Show all ${widget.items.length} searches',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import 'settings_controller.dart';
|
||||
|
||||
final hiddenPostRowsProvider = FutureProvider<List<HiddenPostRow>>((ref) async {
|
||||
final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final repository = ref.watch(postRepositoryProvider);
|
||||
final rows = <HiddenPostRow>[];
|
||||
for (final key in settings.hiddenPostKeys) {
|
||||
final parts = key.split(':');
|
||||
Post? post;
|
||||
if (parts.length >= 2) {
|
||||
final result = await repository.getCachedPost(
|
||||
parts.sublist(1).join(':'), parts.first);
|
||||
if (result is Success<Post?>) post = result.data;
|
||||
}
|
||||
rows.add(HiddenPostRow(cacheKey: key, post: post));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
class HiddenPostsScreen extends ConsumerWidget {
|
||||
const HiddenPostsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rows = ref.watch(hiddenPostRowsProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Hidden posts',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Restore all',
|
||||
onPressed: rows.valueOrNull?.isEmpty ?? true
|
||||
? null
|
||||
: () async {
|
||||
await ref.read(settingsServiceProvider).clearHiddenPosts();
|
||||
ref.invalidate(appSettingsProvider);
|
||||
ref.invalidate(settingsControllerProvider);
|
||||
ref.invalidate(hiddenPostRowsProvider);
|
||||
},
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
),
|
||||
],
|
||||
body: rows.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text(error.toString())),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(child: Text('No hidden posts'))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _HiddenPostTile(
|
||||
item: item,
|
||||
onOpen: item.post == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'/post/${item.post!.providerId}/${item.post!.id}',
|
||||
extra: item.post,
|
||||
),
|
||||
onRestore: () async {
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.unhidePostKey(item.cacheKey);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
ref.invalidate(settingsControllerProvider);
|
||||
ref.invalidate(hiddenPostRowsProvider);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HiddenPostRow {
|
||||
const HiddenPostRow({required this.cacheKey, this.post});
|
||||
|
||||
final String cacheKey;
|
||||
final Post? post;
|
||||
}
|
||||
|
||||
class _HiddenPostTile extends StatelessWidget {
|
||||
const _HiddenPostTile({
|
||||
required this.item,
|
||||
required this.onRestore,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
final HiddenPostRow item;
|
||||
final VoidCallback onRestore;
|
||||
final VoidCallback? onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final post = item.post;
|
||||
final imageUrl =
|
||||
post == null ? null : MediaUrlSelector.preview(post).firstOrNull;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: onOpen,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.visibility_off_rounded)
|
||||
: CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
title: Text(post?.tags.take(4).join(' ') ?? item.cacheKey),
|
||||
subtitle: Text(post?.providerName ?? item.cacheKey),
|
||||
trailing: TextButton(
|
||||
onPressed: onRestore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/services.dart' hide appBuildNumber;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -101,8 +101,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.autoDownloadFavorites,
|
||||
title: Text(strings.autoDownloadFavorites),
|
||||
subtitle: Text(strings.autoDownloadFavoritesHint),
|
||||
title: Text(settings.languageCode == 'ru'
|
||||
? 'Offline избранное'
|
||||
: 'Offline favorites'),
|
||||
subtitle: Text(settings.languageCode == 'ru'
|
||||
? 'При добавлении фото или видео в избранное файл скачивается в фоне для просмотра офлайн.'
|
||||
: 'When a photo or video is added to Favorites, Lunaris downloads it in the background for offline viewing.'),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(autoDownloadFavorites: value),
|
||||
@@ -283,6 +287,13 @@ class _SettingsContent extends ConsumerWidget {
|
||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||
label: Text(strings.clearViewed),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: settings.hiddenPostKeys.isEmpty
|
||||
? null
|
||||
: () => context.go('/settings/hidden'),
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
label: const Text('Restore hidden'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
@@ -741,13 +752,19 @@ class _ColorSwatches extends StatelessWidget {
|
||||
final int selected;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
static const colors = <int>[
|
||||
0xFFE84D8A,
|
||||
0xFF8B5CF6,
|
||||
0xFF3B82F6,
|
||||
0xFF14B8A6,
|
||||
0xFFF97316,
|
||||
0xFFEF4444,
|
||||
static const colors = <(int, String)>[
|
||||
(0xFFE84D8A, 'Lunaris pink'),
|
||||
(0xFF8B5CF6, 'Violet'),
|
||||
(0xFF3B82F6, 'Azure'),
|
||||
(0xFF0EA5E9, 'Sky'),
|
||||
(0xFF14B8A6, 'Teal'),
|
||||
(0xFF22C55E, 'Green'),
|
||||
(0xFFEAB308, 'Amber'),
|
||||
(0xFFF97316, 'Orange'),
|
||||
(0xFFEF4444, 'Red'),
|
||||
(0xFFEC4899, 'Hot pink'),
|
||||
(0xFF6366F1, 'Indigo'),
|
||||
(0xFF64748B, 'Slate'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -761,28 +778,28 @@ class _ColorSwatches extends StatelessWidget {
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final value in colors)
|
||||
for (final entry in colors)
|
||||
Tooltip(
|
||||
message:
|
||||
'#${value.toRadixString(16).substring(2).toUpperCase()}',
|
||||
'${entry.$2} #${entry.$1.toRadixString(16).substring(2).toUpperCase()}',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => onChanged(value),
|
||||
onTap: () => onChanged(entry.$1),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(value),
|
||||
color: Color(entry.$1),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected == value
|
||||
color: selected == entry.$1
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: selected == value ? 3 : 1,
|
||||
width: selected == entry.$1 ? 3 : 1,
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 38,
|
||||
height: 38,
|
||||
child: selected == value
|
||||
child: selected == entry.$1
|
||||
? const Icon(Icons.check_rounded, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -4,11 +4,13 @@ import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
final viewedControllerProvider =
|
||||
AsyncNotifierProvider<ViewedController, List<Post>>(ViewedController.new);
|
||||
AsyncNotifierProvider<ViewedController, List<ViewedTimelineGroup>>(
|
||||
ViewedController.new,
|
||||
);
|
||||
|
||||
class ViewedController extends AsyncNotifier<List<Post>> {
|
||||
class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
|
||||
@override
|
||||
Future<List<Post>> build() => _load();
|
||||
Future<List<ViewedTimelineGroup>> build() => _load();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = AsyncData(await _load());
|
||||
@@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier<List<Post>> {
|
||||
state = const AsyncData([]);
|
||||
}
|
||||
|
||||
Future<List<Post>> _load() async {
|
||||
Future<List<ViewedTimelineGroup>> _load() async {
|
||||
final result =
|
||||
await ref.read(viewedHistoryServiceProvider).getViewedPosts();
|
||||
return result is Success<List<Post>> ? result.data : const [];
|
||||
await ref.read(viewedHistoryServiceProvider).getViewedPostEntries();
|
||||
if (result is! Success<List<ViewedPostEntry>>) return const [];
|
||||
return groupViewedTimeline(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewedTimelineGroup {
|
||||
const ViewedTimelineGroup({required this.label, required this.items});
|
||||
|
||||
final String label;
|
||||
final List<ViewedPostEntry> items;
|
||||
}
|
||||
|
||||
List<ViewedTimelineGroup> groupViewedTimeline(
|
||||
List<ViewedPostEntry> entries, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final today = DateTime(now?.year ?? DateTime.now().year,
|
||||
now?.month ?? DateTime.now().month, now?.day ?? DateTime.now().day);
|
||||
final buckets = <String, List<ViewedPostEntry>>{};
|
||||
for (final entry in entries) {
|
||||
final date =
|
||||
DateTime(entry.viewedAt.year, entry.viewedAt.month, entry.viewedAt.day);
|
||||
final days = today.difference(date).inDays;
|
||||
final label = switch (days) {
|
||||
0 => 'Today',
|
||||
1 => 'Yesterday',
|
||||
_ => _dateLabel(date),
|
||||
};
|
||||
buckets.putIfAbsent(label, () => []).add(entry);
|
||||
}
|
||||
return [
|
||||
for (final entry in buckets.entries)
|
||||
ViewedTimelineGroup(label: entry.key, items: entry.value),
|
||||
];
|
||||
}
|
||||
|
||||
String _dateLabel(DateTime date) =>
|
||||
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
@@ -3,12 +3,10 @@ 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 '../../../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_controller.dart';
|
||||
|
||||
@@ -21,7 +19,6 @@ class ViewedScreen extends ConsumerWidget {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||
actions: [
|
||||
@@ -34,47 +31,120 @@ class ViewedScreen extends ConsumerWidget {
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (posts) => posts.isEmpty
|
||||
data: (groups) => groups.isEmpty
|
||||
? const EmptyView(title: 'No viewed posts yet')
|
||||
: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: PostMasonryGrid(
|
||||
posts: posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||
child: Text(
|
||||
group.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
_ViewedPostTile(
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
onOpen: () {
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
AppSettings settings,
|
||||
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,
|
||||
settings: settings,
|
||||
downloadManager: ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewedPostTile extends StatelessWidget {
|
||||
const _ViewedPostTile({
|
||||
required this.post,
|
||||
required this.viewedAt,
|
||||
required this.isFavorite,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final DateTime viewedAt;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = MediaUrlSelector.preview(post).firstOrNull;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
onTap: onOpen,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: image == null
|
||||
? const Icon(Icons.image_not_supported_rounded)
|
||||
: Image.network(image, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
title: Text(post.tags.take(4).join(' '), maxLines: 1),
|
||||
subtitle: Text(
|
||||
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,16 @@ class TagInputSearchBar extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final _fieldKey = GlobalKey();
|
||||
final _layerLink = LayerLink();
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
late final ScrollController _tagScrollController;
|
||||
OverlayEntry? _suggestionsOverlay;
|
||||
Timer? _debounce;
|
||||
List<String> _tags = [];
|
||||
String _lastExternalValue = '';
|
||||
bool _localDirty = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -59,21 +64,30 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_focusNode = FocusNode();
|
||||
_tagScrollController = ScrollController();
|
||||
_focusNode.addListener(_handleFocusChanged);
|
||||
_setFromQuery(widget.initialValue ?? '');
|
||||
_lastExternalValue = widget.initialValue ?? '';
|
||||
_setFromQuery(_lastExternalValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.initialValue ?? '';
|
||||
if (!_focusNode.hasFocus && oldWidget.initialValue != next) {
|
||||
final externalChanged = next != _lastExternalValue;
|
||||
final externalCleared = next.isEmpty && _lastExternalValue.isNotEmpty;
|
||||
if (externalChanged && (externalCleared || !_isEditing)) {
|
||||
_setFromQuery(next);
|
||||
_lastExternalValue = next;
|
||||
_localDirty = false;
|
||||
}
|
||||
if (oldWidget.suggestions != widget.suggestions) {
|
||||
_syncSuggestionsOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_removeSuggestionsOverlay();
|
||||
_tagScrollController.dispose();
|
||||
_focusNode.removeListener(_handleFocusChanged);
|
||||
_focusNode.dispose();
|
||||
@@ -82,114 +96,149 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
}
|
||||
|
||||
void _handleFocusChanged() {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
if (!_focusNode.hasFocus && !_localDirty) {
|
||||
final next = widget.initialValue ?? '';
|
||||
if (next != _lastExternalValue) {
|
||||
_setFromQuery(next);
|
||||
_lastExternalValue = next;
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
_syncSuggestionsOverlay();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.45)
|
||||
: Colors.transparent,
|
||||
),
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _syncSuggestionsOverlay();
|
||||
});
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: DecoratedBox(
|
||||
key: _fieldKey,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.45)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Scrollbar(
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Scrollbar(
|
||||
controller: _tagScrollController,
|
||||
thumbVisibility: false,
|
||||
child: SingleChildScrollView(
|
||||
controller: _tagScrollController,
|
||||
thumbVisibility: false,
|
||||
child: SingleChildScrollView(
|
||||
controller: _tagScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final tag in _tags) ...[
|
||||
_buildTagChip(context, tag),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 120,
|
||||
maxWidth: 300,
|
||||
),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: _handleDraftChanged,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
hintText: _tags.isEmpty
|
||||
? widget.hintText
|
||||
: 'tag',
|
||||
prefixIcon: null,
|
||||
suffixIcon: null,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final tag in _tags) ...[
|
||||
_buildTagChip(context, tag),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 120,
|
||||
maxWidth: 300,
|
||||
),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: _handleDraftChanged,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
hintText:
|
||||
_tags.isEmpty ? widget.hintText : 'tag',
|
||||
prefixIcon: null,
|
||||
suffixIcon: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.suggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_TagSuggestionDropdown(
|
||||
suggestions: widget.suggestions,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncSuggestionsOverlay() {
|
||||
final token = _activeToken;
|
||||
final suggestions = widget.suggestions
|
||||
.where((item) => item.name.toLowerCase().startsWith(token))
|
||||
.toList(growable: false);
|
||||
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
|
||||
_removeSuggestionsOverlay();
|
||||
return;
|
||||
}
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
|
||||
|
||||
final size = renderBox.size;
|
||||
_removeSuggestionsOverlay();
|
||||
_suggestionsOverlay = OverlayEntry(
|
||||
builder: (context) => _TagSuggestionOverlay(
|
||||
link: _layerLink,
|
||||
width: size.width,
|
||||
yOffset: size.height + 8,
|
||||
suggestions: suggestions,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
);
|
||||
overlay.insert(_suggestionsOverlay!);
|
||||
}
|
||||
|
||||
void _removeSuggestionsOverlay() {
|
||||
_suggestionsOverlay?.remove();
|
||||
_suggestionsOverlay = null;
|
||||
}
|
||||
|
||||
Widget _buildTagChip(BuildContext context, String tag) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InputChip(
|
||||
@@ -234,6 +283,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
}
|
||||
|
||||
void _handleDraftChanged(String value) {
|
||||
_localDirty = true;
|
||||
final composing = _controller.value.composing;
|
||||
if (!composing.isValid &&
|
||||
value.isNotEmpty &&
|
||||
@@ -252,6 +302,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
void _commitDraft(String value) {
|
||||
final additions = _parseTags(value);
|
||||
if (additions.isEmpty) return;
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
for (final tag in additions) {
|
||||
if (!_tags.contains(tag)) _tags.add(tag);
|
||||
@@ -264,11 +315,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
void _submit() {
|
||||
_debounce?.cancel();
|
||||
_commitDraft(_controller.text);
|
||||
_removeSuggestionsOverlay();
|
||||
_lastExternalValue = _query;
|
||||
_localDirty = false;
|
||||
widget.onSubmitted(_query);
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_debounce?.cancel();
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
_tags = [];
|
||||
_controller.clear();
|
||||
@@ -278,6 +333,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
}
|
||||
|
||||
void _editTag(String tag) {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
_controller.value = TextEditingValue(
|
||||
@@ -290,6 +346,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
}
|
||||
|
||||
void _removeTag(String tag) {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
});
|
||||
@@ -298,6 +355,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
final draft = _controller.text.trim();
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
if (draft.isNotEmpty) {
|
||||
final draftTags = _parseTags(draft);
|
||||
@@ -307,6 +365,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
_removeSuggestionsOverlay();
|
||||
widget.onSuggestionApplied?.call(_query);
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
@@ -317,7 +376,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
}
|
||||
|
||||
void _notifyChanged() {
|
||||
_localDirty = true;
|
||||
widget.onChanged?.call(_query);
|
||||
_syncSuggestionsOverlay();
|
||||
}
|
||||
|
||||
List<String> _parseTags(String query) {
|
||||
@@ -336,6 +397,51 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final all = [..._tags, if (draft.isNotEmpty) draft];
|
||||
return all.join(' ');
|
||||
}
|
||||
|
||||
String get _activeToken {
|
||||
final draft = _controller.text.trim().toLowerCase();
|
||||
if (draft.isEmpty) return '';
|
||||
return draft.split(RegExp(r'\s+')).last;
|
||||
}
|
||||
|
||||
bool get _isEditing => _focusNode.hasFocus || _localDirty;
|
||||
}
|
||||
|
||||
class _TagSuggestionOverlay extends StatelessWidget {
|
||||
const _TagSuggestionOverlay({
|
||||
required this.link,
|
||||
required this.width,
|
||||
required this.yOffset,
|
||||
required this.suggestions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final LayerLink link;
|
||||
final double width;
|
||||
final double yOffset;
|
||||
final List<TagSuggestion> suggestions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: CompositedTransformFollower(
|
||||
link: link,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, yOffset),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: width.clamp(280, 620).toDouble(),
|
||||
child: _TagSuggestionDropdown(
|
||||
suggestions: suggestions,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagSuggestionDropdown extends StatelessWidget {
|
||||
@@ -350,62 +456,59 @@ class _TagSuggestionDropdown extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.18),
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: suggestions.length > 8 ? 8 : suggestions.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
indent: 52,
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.55),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return InkWell(
|
||||
onTap: () => onSelected(suggestion.name),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_TagSuggestionBadge(suggestion.categoryLabel),
|
||||
if (suggestion.postCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_compactCount(suggestion.postCount),
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.18),
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: suggestions.length > 8 ? 8 : suggestions.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
indent: 52,
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.55),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return InkWell(
|
||||
onTap: () => onSelected(suggestion.name),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_TagSuggestionBadge(suggestion.categoryLabel),
|
||||
if (suggestion.postCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_compactCount(suggestion.postCount),
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -287,7 +287,9 @@ class _MobileShell extends StatelessWidget {
|
||||
? null
|
||||
: FloatingActionButton.small(
|
||||
heroTag: 'mobile-settings',
|
||||
tooltip: ru ? 'Настройки' : 'Settings',
|
||||
tooltip: ru
|
||||
? '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'
|
||||
: 'Settings',
|
||||
onPressed: () => context.go('/settings'),
|
||||
child: const Icon(Icons.settings_rounded),
|
||||
),
|
||||
@@ -325,14 +327,15 @@ class _Destination {
|
||||
String labelFor(bool ru) {
|
||||
if (!ru) return label;
|
||||
return switch (id) {
|
||||
'feed' => 'Лента',
|
||||
'search' => 'Поиск',
|
||||
'favorites' => 'Избранное',
|
||||
'viewed' => 'История',
|
||||
'collections' => 'Коллекции',
|
||||
'artists' => 'Артисты',
|
||||
'providers' => 'Провайдеры',
|
||||
'settings' => 'Настройки',
|
||||
'feed' => '\u041b\u0435\u043d\u0442\u0430',
|
||||
'search' => '\u041f\u043e\u0438\u0441\u043a',
|
||||
'favorites' => '\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435',
|
||||
'viewed' => '\u0418\u0441\u0442\u043e\u0440\u0438\u044f',
|
||||
'collections' => '\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438',
|
||||
'artists' => '\u0410\u0440\u0442\u0438\u0441\u0442\u044b',
|
||||
'providers' =>
|
||||
'\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b',
|
||||
'settings' => '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
|
||||
_ => label,
|
||||
};
|
||||
}
|
||||
@@ -340,8 +343,8 @@ class _Destination {
|
||||
String mobileLabelFor(bool ru) {
|
||||
if (!ru) return mobileLabel;
|
||||
return switch (id) {
|
||||
'collections' => 'Доски',
|
||||
'favorites' => 'Любимое',
|
||||
'collections' => '\u0414\u043e\u0441\u043a\u0438',
|
||||
'favorites' => '\u041b\u044e\u0431\u0438\u043c\u043e\u0435',
|
||||
_ => labelFor(true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../app/motion.dart';
|
||||
import '../../backend/backend.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import 'blur_content.dart';
|
||||
import 'loading_skeleton.dart';
|
||||
import 'rating_badge.dart';
|
||||
|
||||
class PostCard extends StatefulWidget {
|
||||
class PostCard extends ConsumerStatefulWidget {
|
||||
const PostCard({
|
||||
required this.post,
|
||||
required this.blurExplicit,
|
||||
@@ -44,25 +51,40 @@ class PostCard extends StatefulWidget {
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
State<PostCard> createState() => _PostCardState();
|
||||
ConsumerState<PostCard> createState() => _PostCardState();
|
||||
}
|
||||
|
||||
class _PostCardState extends State<PostCard> {
|
||||
class _PostCardState extends ConsumerState<PostCard> {
|
||||
static final Map<String, Post> _resolvedRealbooruPosts = {};
|
||||
|
||||
bool _hovered = false;
|
||||
Post? _resolvedPost;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
|
||||
_maybeResolveRealbooruPost();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PostCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.post.cacheKey != widget.post.cacheKey) {
|
||||
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
|
||||
_maybeResolveRealbooruPost();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final post = widget.post;
|
||||
final post = _resolvedPost ?? widget.post;
|
||||
final sensitive = _isSensitive(post.rating);
|
||||
final aspect = post.width > 0 && post.height > 0
|
||||
? post.width / post.height
|
||||
: _fallbackAspect(post.fileType);
|
||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||
final urls = MediaUrlSelector.feed(
|
||||
post,
|
||||
mode: widget.mediaQualityMode,
|
||||
mobile: mobile,
|
||||
);
|
||||
final urls = _feedUrls(post, mobile: mobile);
|
||||
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
@@ -130,8 +152,23 @@ class _PostCardState extends State<PostCard> {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
videoUrl: videoUrls.first,
|
||||
headers: headers,
|
||||
fallbackImageUrl: imageUrl,
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: headers,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
@@ -291,6 +328,236 @@ class _PostCardState extends State<PostCard> {
|
||||
if (normalized.contains('gif')) return 1;
|
||||
return 0.72;
|
||||
}
|
||||
|
||||
List<String> _feedUrls(Post post, {required bool mobile}) {
|
||||
if ((post.providerId == 'realbooru' || post.providerId == 'paheal') &&
|
||||
!MediaUrlSelector.isVideo(post)) {
|
||||
return [
|
||||
post.sampleUrl,
|
||||
post.fileUrl,
|
||||
post.previewUrl,
|
||||
].where((url) => url.trim().isNotEmpty).toSet().toList(growable: false);
|
||||
}
|
||||
return MediaUrlSelector.feed(
|
||||
post,
|
||||
mode: widget.mediaQualityMode,
|
||||
mobile: mobile,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
final lower = '${post.providerId} ${post.providerName} '
|
||||
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
|
||||
.toLowerCase();
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
|
||||
if (lower.contains('realbooru'))
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
|
||||
if (lower.contains('realbooru'))
|
||||
'Accept':
|
||||
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
if (lower.contains('realbooru'))
|
||||
'Referer':
|
||||
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
|
||||
if (lower.contains('paheal'))
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
|
||||
if (lower.contains('paheal'))
|
||||
'Accept':
|
||||
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
if (lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
|
||||
if (lower.contains('rule34') && !lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
}
|
||||
|
||||
void _maybeResolveRealbooruPost() {
|
||||
final post = widget.post;
|
||||
if (!_needsRealbooruDetails(post) ||
|
||||
_resolvedRealbooruPosts.containsKey(post.cacheKey)) {
|
||||
return;
|
||||
}
|
||||
Future<void>(() async {
|
||||
final result = await ref
|
||||
.read(providerManagerProvider)
|
||||
.getPost(post.providerId, post.id);
|
||||
if (!mounted) return;
|
||||
if (result is Success<Post?> && result.data != null) {
|
||||
final resolved = result.data!;
|
||||
_resolvedRealbooruPosts[post.cacheKey] = resolved;
|
||||
setState(() => _resolvedPost = resolved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _needsRealbooruDetails(Post post) {
|
||||
if (post.providerId != 'realbooru') return false;
|
||||
final preview = post.previewUrl.toLowerCase();
|
||||
final sample = post.sampleUrl.toLowerCase();
|
||||
return post.fileUrl.isEmpty ||
|
||||
post.fileUrl == post.previewUrl ||
|
||||
post.fileUrl == post.sampleUrl ||
|
||||
preview.contains('/thumbnails/') ||
|
||||
sample.contains('/thumbnails/');
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedVideoPreview extends StatefulWidget {
|
||||
const _FeedVideoPreview({
|
||||
required this.videoUrl,
|
||||
required this.headers,
|
||||
required this.fallbackImageUrl,
|
||||
required this.cacheWidth,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
final Map<String, String> headers;
|
||||
final String fallbackImageUrl;
|
||||
final int cacheWidth;
|
||||
|
||||
@override
|
||||
State<_FeedVideoPreview> createState() => _FeedVideoPreviewState();
|
||||
}
|
||||
|
||||
class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
|
||||
late final Player _player;
|
||||
late final VideoController _controller;
|
||||
bool _failed = false;
|
||||
bool _ready = false;
|
||||
int _requestId = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
MediaKit.ensureInitialized();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_player.stream.error.listen((_) {
|
||||
if (mounted) setState(() => _failed = true);
|
||||
});
|
||||
_open();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _FeedVideoPreview oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoUrl != widget.videoUrl) {
|
||||
_open();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fallback = CachedNetworkImage(
|
||||
imageUrl: widget.fallbackImageUrl,
|
||||
httpHeaders: widget.headers,
|
||||
memCacheWidth: widget.cacheWidth,
|
||||
maxWidthDiskCache: widget.cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.videocam_off_rounded)),
|
||||
),
|
||||
);
|
||||
if (_failed) return fallback;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
fallback,
|
||||
AnimatedOpacity(
|
||||
opacity: _ready ? 1 : 0,
|
||||
duration: AppMotion.duration(context, 180),
|
||||
child: ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Video(
|
||||
controller: _controller,
|
||||
fit: BoxFit.cover,
|
||||
controls: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
final requestId = ++_requestId;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_failed = false;
|
||||
_ready = false;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await _VideoPreviewOpenQueue.run(() async {
|
||||
if (!mounted || requestId != _requestId) return;
|
||||
await _player.stop();
|
||||
await _player
|
||||
.open(
|
||||
Media(widget.videoUrl, httpHeaders: widget.headers),
|
||||
play: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (!mounted || requestId != _requestId) return;
|
||||
await _player
|
||||
.seek(const Duration(milliseconds: 300))
|
||||
.timeout(const Duration(seconds: 3));
|
||||
await _player.pause();
|
||||
});
|
||||
if (!mounted || requestId != _requestId) return;
|
||||
setState(() => _ready = true);
|
||||
} catch (_) {
|
||||
if (mounted && requestId == _requestId) {
|
||||
setState(() => _failed = true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPreviewOpenQueue {
|
||||
static const int _maxConcurrent = 2;
|
||||
static final Queue<Future<void> Function()> _pending = Queue();
|
||||
static int _active = 0;
|
||||
|
||||
static Future<void> run(Future<void> Function() task) {
|
||||
final completer = Completer<void>();
|
||||
_pending.add(() async {
|
||||
try {
|
||||
await task();
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
} catch (error, stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
_pump();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
static void _pump() {
|
||||
while (_active < _maxConcurrent && _pending.isNotEmpty) {
|
||||
final job = _pending.removeFirst();
|
||||
_active++;
|
||||
unawaited(
|
||||
job().whenComplete(() {
|
||||
_active--;
|
||||
_pump();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteButton extends StatelessWidget {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TagChip extends StatelessWidget {
|
||||
const TagChip({required this.tag, this.onTap, super.key});
|
||||
const TagChip({
|
||||
required this.tag,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String tag;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ActionChip(
|
||||
final chip = ActionChip(
|
||||
onPressed: onTap,
|
||||
label: Text(tag),
|
||||
labelStyle: TextStyle(
|
||||
@@ -20,5 +26,11 @@ class TagChip extends StatelessWidget {
|
||||
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
if (onLongPress == null) return chip;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: onLongPress,
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-16
@@ -627,10 +627,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -683,10 +683,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1064,10 +1064,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.12"
|
||||
time:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1184,10 +1184,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.4.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1252,14 +1252,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webview_windows:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_windows
|
||||
sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-2
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.1+22
|
||||
version: 2.0.2+24
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
@@ -31,7 +31,6 @@ dependencies:
|
||||
file_selector: ^1.0.3
|
||||
path: ^1.9.0
|
||||
media_kit_libs_android_video: ^1.3.8
|
||||
webview_windows: ^0.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -50,6 +50,14 @@ void main() {
|
||||
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
|
||||
});
|
||||
|
||||
test('requested search tags can match tag prefixes', () {
|
||||
final post = _post(tags: ['zenless_zone_zero', 'ellen_joe']);
|
||||
|
||||
expect(postMatchesRequestedTags(post, ['zenless']), isTrue);
|
||||
expect(postMatchesRequestedTags(post, ['zenless', 'ellen']), isTrue);
|
||||
expect(postMatchesRequestedTags(post, ['zone']), isFalse);
|
||||
});
|
||||
|
||||
test('hidden post keys are filtered locally', () {
|
||||
final post = _post(tags: ['touhou']);
|
||||
final settings = AppSettings.defaults.copyWith(
|
||||
|
||||
@@ -38,9 +38,13 @@ void main() {
|
||||
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
|
||||
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
|
||||
final localAppData = Platform.environment['LOCALAPPDATA'];
|
||||
final source = File(
|
||||
final bundled = File(
|
||||
'${Directory.current.path}\\third_party\\isar_flutter_libs\\windows\\isar.dll',
|
||||
);
|
||||
final cached = File(
|
||||
'$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll',
|
||||
);
|
||||
final source = bundled.existsSync() ? bundled : cached;
|
||||
if (source.existsSync()) {
|
||||
await source.copy(copiedIsarDll.path);
|
||||
}
|
||||
@@ -78,42 +82,32 @@ void main() {
|
||||
expect(providers.map((provider) => provider.id), [
|
||||
'gelbooru',
|
||||
'rule34',
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'realbooru',
|
||||
'e621',
|
||||
'e926',
|
||||
'realbooru',
|
||||
'xbooru',
|
||||
'cosbooru',
|
||||
'kemono',
|
||||
'coomer',
|
||||
'pawchive',
|
||||
]);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'cosbooru').enabled,
|
||||
isTrue,
|
||||
);
|
||||
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
|
||||
expect(xbooru.enabled, isTrue);
|
||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
||||
expect(xbooru.apiType, 'gelbooru');
|
||||
final cosbooru =
|
||||
providers.singleWhere((provider) => provider.id == 'cosbooru');
|
||||
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
|
||||
expect(cosbooru.apiType, 'danbooru');
|
||||
final kemono = providers.singleWhere((provider) => provider.id == 'kemono');
|
||||
final coomer = providers.singleWhere((provider) => provider.id == 'coomer');
|
||||
expect(kemono.enabled, isTrue);
|
||||
expect(kemono.apiType, 'kemono');
|
||||
expect(coomer.enabled, isFalse);
|
||||
expect(coomer.apiType, 'coomer');
|
||||
final pawchive =
|
||||
providers.singleWhere((provider) => provider.id == 'pawchive');
|
||||
expect(pawchive.enabled, isTrue);
|
||||
expect(pawchive.apiType, 'pawchive');
|
||||
expect(pawchive.baseUrl, 'https://pawchive.pw');
|
||||
final realbooru =
|
||||
providers.singleWhere((provider) => provider.id == 'realbooru');
|
||||
expect(realbooru.enabled, isTrue);
|
||||
expect(realbooru.apiType, 'realbooru_html');
|
||||
expect(realbooru.baseUrl, 'https://realbooru.com');
|
||||
expect(providers.where((provider) => provider.id == 'safebooru'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'konachan'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'yandere'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'xbooru'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'paheal'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'kemono'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'coomer'), isEmpty);
|
||||
});
|
||||
|
||||
test('provider repository migrates old CosBooru config', () async {
|
||||
test('provider repository disables old CosBooru config without deleting it',
|
||||
() async {
|
||||
final repository = ProviderRepository(databaseService);
|
||||
final now = DateTime(2026);
|
||||
await repository.saveProvider(ContentProviderConfig(
|
||||
@@ -134,10 +128,10 @@ void main() {
|
||||
final cosbooru =
|
||||
providers.singleWhere((provider) => provider.id == 'cosbooru');
|
||||
|
||||
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
|
||||
expect(cosbooru.apiType, 'danbooru');
|
||||
expect(cosbooru.enabled, isTrue);
|
||||
expect(cosbooru.priority, 9);
|
||||
expect(cosbooru.baseUrl, 'https://cos.booru.nl');
|
||||
expect(cosbooru.apiType, 'gelbooru');
|
||||
expect(cosbooru.enabled, isFalse);
|
||||
expect(cosbooru.priority, 99);
|
||||
});
|
||||
|
||||
test('settings saveEnabledProviders updates provider configs', () async {
|
||||
@@ -148,11 +142,11 @@ void main() {
|
||||
providerRepository: providerRepository,
|
||||
);
|
||||
|
||||
await service.saveEnabledProviders(['safebooru']);
|
||||
await service.saveEnabledProviders(['realbooru']);
|
||||
final providers = (await providerRepository.getProviders() as Success).data;
|
||||
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'safebooru').enabled,
|
||||
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
|
||||
isTrue);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/pawchive_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/core/http/dio_client.dart';
|
||||
|
||||
void main() {
|
||||
test('ProviderFactory creates PawchiveProvider', () {
|
||||
final factory = ProviderFactory();
|
||||
final config = ContentProviderConfig(
|
||||
id: 'pawchive',
|
||||
name: 'Pawchive',
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
apiType: 'pawchive',
|
||||
enabled: true,
|
||||
priority: 12,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: DateTime(2026),
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
final provider = factory.create(config);
|
||||
expect(provider, isA<PawchiveProvider>());
|
||||
expect(provider.id, 'pawchive');
|
||||
expect(provider.name, 'Pawchive');
|
||||
expect(provider.baseUrl, 'https://pawchive.pw');
|
||||
});
|
||||
|
||||
test('PawchiveProvider creates posts with thumbnail and file URLs', () async {
|
||||
final client = DioClient(baseUrl: 'https://pawchive.pw');
|
||||
final provider = PawchiveProvider(
|
||||
id: 'pawchive',
|
||||
name: 'Pawchive',
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
dioClient: client,
|
||||
);
|
||||
|
||||
const query = ArtistWorkQuery(
|
||||
providerId: 'pawchive',
|
||||
service: 'patreon',
|
||||
artistId: '30500811',
|
||||
artistName: 'BOKABA',
|
||||
);
|
||||
|
||||
expect(provider.id, query.providerId);
|
||||
expect(query.artistName, 'BOKABA');
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/backend/models/provider_diagnostics.dart';
|
||||
import 'package:gel_rule_app/backend/models/provider_health.dart';
|
||||
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
|
||||
import 'package:gel_rule_app/backend/models/top_period_filter.dart';
|
||||
import 'package:gel_rule_app/backend/providers/content_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
@@ -76,8 +77,15 @@ class FakeProviderFactory extends ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) => providers[config.id]!;
|
||||
}
|
||||
|
||||
class FakeProvider implements ContentProvider {
|
||||
FakeProvider(this.id, this.name, this.posts, {this.failSearch = false});
|
||||
class FakeProvider implements ContentProvider, TagSuggestionProvider {
|
||||
FakeProvider(
|
||||
this.id,
|
||||
this.name,
|
||||
this.posts, {
|
||||
this.failSearch = false,
|
||||
this.suggestions = const [],
|
||||
this.suggestionDelay = Duration.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@@ -87,6 +95,9 @@ class FakeProvider implements ContentProvider {
|
||||
String get baseUrl => 'https://example.test';
|
||||
final List<Post> posts;
|
||||
final bool failSearch;
|
||||
final List<TagSuggestion> suggestions;
|
||||
final Duration suggestionDelay;
|
||||
int suggestionCalls = 0;
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
||||
@@ -111,6 +122,19 @@ class FakeProvider implements ContentProvider {
|
||||
if (failSearch) throw Exception('fail');
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
suggestionCalls++;
|
||||
if (suggestionDelay > Duration.zero) {
|
||||
await Future<void>.delayed(suggestionDelay);
|
||||
}
|
||||
return suggestions
|
||||
.where((suggestion) => suggestion.name.startsWith(query))
|
||||
.take(limit)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
ContentProviderConfig config(String id, int priority, {bool enabled = true}) {
|
||||
@@ -224,4 +248,182 @@ void main() {
|
||||
await manager.enableProvider('a', false);
|
||||
expect(repository.configs['a']!.enabled, isFalse);
|
||||
});
|
||||
|
||||
test('tag suggestions query every active suggestion provider before limiting',
|
||||
() async {
|
||||
final repository = FakeProviderRepository()
|
||||
..configs['gelbooru'] = config('gelbooru', 0)
|
||||
..configs['e621'] = config('e621', 1)
|
||||
..configs['e926'] = config('e926', 2);
|
||||
final manager = ProviderManager(
|
||||
repository,
|
||||
FakeProviderFactory({
|
||||
'gelbooru': FakeProvider(
|
||||
'gelbooru',
|
||||
'Gelbooru',
|
||||
[],
|
||||
suggestions: [
|
||||
for (var i = 0; i < 4; i++)
|
||||
TagSuggestion(
|
||||
name: 'cat_$i',
|
||||
category: TagCategory.general,
|
||||
postCount: 100 - i,
|
||||
providerId: 'gelbooru',
|
||||
),
|
||||
],
|
||||
),
|
||||
'e621': FakeProvider(
|
||||
'e621',
|
||||
'e621',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat_tail',
|
||||
category: TagCategory.general,
|
||||
postCount: 1000,
|
||||
providerId: 'e621',
|
||||
),
|
||||
],
|
||||
),
|
||||
'e926': FakeProvider(
|
||||
'e926',
|
||||
'e926',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat_ears',
|
||||
category: TagCategory.general,
|
||||
postCount: 900,
|
||||
providerId: 'e926',
|
||||
),
|
||||
],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
final result = await manager.suggestTags('cat', limit: 4)
|
||||
as Success<List<TagSuggestion>>;
|
||||
|
||||
expect(result.data.map((item) => item.providerId).toSet(), {
|
||||
'gelbooru',
|
||||
'e621',
|
||||
'e926',
|
||||
});
|
||||
});
|
||||
|
||||
test('tag suggestions timeout slow providers without blocking fast results',
|
||||
() async {
|
||||
final repository = FakeProviderRepository()
|
||||
..configs['fast'] = config('fast', 0)
|
||||
..configs['slow'] = config('slow', 1);
|
||||
final fast = FakeProvider(
|
||||
'fast',
|
||||
'fast',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat_fast',
|
||||
category: TagCategory.general,
|
||||
postCount: 20,
|
||||
providerId: 'fast',
|
||||
),
|
||||
],
|
||||
);
|
||||
final slow = FakeProvider(
|
||||
'slow',
|
||||
'slow',
|
||||
[],
|
||||
suggestionDelay: const Duration(seconds: 3),
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat_slow',
|
||||
category: TagCategory.general,
|
||||
postCount: 200,
|
||||
providerId: 'slow',
|
||||
),
|
||||
],
|
||||
);
|
||||
final manager = ProviderManager(
|
||||
repository,
|
||||
FakeProviderFactory({'fast': fast, 'slow': slow}),
|
||||
);
|
||||
final startedAt = DateTime.now();
|
||||
|
||||
final result = await manager.suggestTags('cat', limit: 4)
|
||||
as Success<List<TagSuggestion>>;
|
||||
|
||||
expect(DateTime.now().difference(startedAt),
|
||||
lessThan(const Duration(milliseconds: 2500)));
|
||||
expect(result.data.map((item) => item.name), ['cat_fast']);
|
||||
});
|
||||
|
||||
test('tag suggestions dedupe by name and keep highest post count', () async {
|
||||
final repository = FakeProviderRepository()
|
||||
..configs['a'] = config('a', 0)
|
||||
..configs['b'] = config('b', 1);
|
||||
final manager = ProviderManager(
|
||||
repository,
|
||||
FakeProviderFactory({
|
||||
'a': FakeProvider(
|
||||
'a',
|
||||
'a',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat',
|
||||
category: TagCategory.general,
|
||||
postCount: 10,
|
||||
providerId: 'a',
|
||||
),
|
||||
],
|
||||
),
|
||||
'b': FakeProvider(
|
||||
'b',
|
||||
'b',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat',
|
||||
category: TagCategory.artist,
|
||||
postCount: 100,
|
||||
providerId: 'b',
|
||||
),
|
||||
],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
final result = await manager.suggestTags('cat', limit: 4)
|
||||
as Success<List<TagSuggestion>>;
|
||||
|
||||
expect(result.data, hasLength(1));
|
||||
expect(result.data.single.providerId, 'b');
|
||||
expect(result.data.single.postCount, 100);
|
||||
});
|
||||
|
||||
test('tag suggestions cache repeated prefix results', () async {
|
||||
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
||||
final provider = FakeProvider(
|
||||
'a',
|
||||
'a',
|
||||
[],
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'cat',
|
||||
category: TagCategory.general,
|
||||
postCount: 10,
|
||||
providerId: 'a',
|
||||
),
|
||||
],
|
||||
);
|
||||
final manager = ProviderManager(
|
||||
repository,
|
||||
FakeProviderFactory({'a': provider}),
|
||||
);
|
||||
|
||||
await manager.suggestTags('cat', limit: 4);
|
||||
await manager.suggestTags('cat', limit: 4);
|
||||
|
||||
expect(provider.suggestionCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/danbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/custom_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/kemono_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
|
||||
|
||||
void main() {
|
||||
@@ -231,44 +231,47 @@ void main() {
|
||||
expect(posts.single.rating, 'unknown');
|
||||
});
|
||||
|
||||
test('seed providers include CosBooru and no Paheal', () {
|
||||
test('seed providers include Realbooru HTML Pawchive and exclude deprecated providers', () {
|
||||
final pawchive = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'pawchive')
|
||||
.single;
|
||||
expect(pawchive.enabled, isTrue);
|
||||
expect(pawchive.apiType, 'pawchive');
|
||||
expect(pawchive.baseUrl, 'https://pawchive.pw');
|
||||
final realbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'realbooru')
|
||||
.single;
|
||||
|
||||
expect(realbooru.enabled, isFalse);
|
||||
expect(realbooru.apiType, 'realbooru');
|
||||
expect(realbooru.enabled, isTrue);
|
||||
expect(realbooru.apiType, 'realbooru_html');
|
||||
expect(realbooru.baseUrl, 'https://realbooru.com');
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'cosbooru'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'paheal'),
|
||||
isEmpty,
|
||||
);
|
||||
final cosbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'cosbooru')
|
||||
.single;
|
||||
final xbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'xbooru')
|
||||
.single;
|
||||
expect(xbooru.enabled, isTrue);
|
||||
expect(xbooru.apiType, 'gelbooru');
|
||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
||||
expect(cosbooru.enabled, isTrue);
|
||||
expect(cosbooru.apiType, 'danbooru');
|
||||
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
|
||||
final kemono = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'kemono')
|
||||
.single;
|
||||
final coomer = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'coomer')
|
||||
.single;
|
||||
expect(kemono.enabled, isTrue);
|
||||
expect(kemono.apiType, 'kemono');
|
||||
expect(coomer.enabled, isFalse);
|
||||
expect(coomer.apiType, 'coomer');
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'xbooru'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'kemono'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'coomer'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('provider factory creates Realbooru provider', () {
|
||||
test('provider factory treats Realbooru api type as unsupported', () {
|
||||
final now = DateTime(2026);
|
||||
final provider = ProviderFactory().create(ContentProviderConfig(
|
||||
id: 'realbooru',
|
||||
@@ -283,25 +286,25 @@ void main() {
|
||||
updatedAt: now,
|
||||
));
|
||||
|
||||
expect(provider, isA<RealbooruProvider>());
|
||||
expect(provider, isA<UnsupportedCustomProvider>());
|
||||
});
|
||||
|
||||
test('provider factory creates CosBooru as Danbooru provider', () {
|
||||
test('provider factory creates Realbooru HTML provider', () {
|
||||
final now = DateTime(2026);
|
||||
final provider = ProviderFactory().create(ContentProviderConfig(
|
||||
id: 'cosbooru',
|
||||
name: 'CosBooru',
|
||||
baseUrl: 'https://cos.lycore.co',
|
||||
apiType: 'danbooru',
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 8,
|
||||
priority: 7,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
|
||||
expect(provider, isA<DanbooruProvider>());
|
||||
expect(provider, isA<RealbooruHtmlProvider>());
|
||||
});
|
||||
|
||||
test('provider factory creates Kemono and Coomer artist providers', () {
|
||||
|
||||
@@ -14,10 +14,10 @@ class FakeSearchRepository implements SearchRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) async {
|
||||
Future<Result<List<SearchHistory>>> recent({int? limit}) async {
|
||||
final sorted = [...items]
|
||||
..sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return Success(sorted.take(limit).toList());
|
||||
return Success((limit == null ? sorted : sorted.take(limit)).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/app_update_info.dart';
|
||||
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||
import 'package:gel_rule_app/backend/services/update_service.dart';
|
||||
import 'package:gel_rule_app/core/database/database_service.dart';
|
||||
|
||||
void main() {
|
||||
test('UpdateService parses Gitea release JSON correctly', () {
|
||||
final service = UpdateService(Dio(), SettingsService(_MockDatabaseService()));
|
||||
|
||||
// Use reflection or constructor to test info
|
||||
final info = AppUpdateInfo(
|
||||
version: '2.0.2',
|
||||
tagName: 'v2.0.2',
|
||||
name: 'Lunaris 2.0.2',
|
||||
body: 'Release notes',
|
||||
htmlUrl: 'https://gitea.rardogsynapse.online/RarDog/Lunaris/releases/tag/v2.0.2',
|
||||
publishedAt: DateTime.parse('2026-09-03T18:00:00Z'),
|
||||
apkUrl: 'https://gitea.rardogsynapse.online/attachments/apk',
|
||||
linuxTarGzUrl: 'https://gitea.rardogsynapse.online/attachments/linux',
|
||||
);
|
||||
|
||||
expect(info.hasAssets, isTrue);
|
||||
expect(info.apkUrl, contains('apk'));
|
||||
expect(info.linuxTarGzUrl, contains('linux'));
|
||||
expect(
|
||||
service.assetFileName(info, 'https://gitea.rardogsynapse.online/attachments/Lunaris-v2.0.2.apk'),
|
||||
'Lunaris-v2.0.2.apk',
|
||||
);
|
||||
expect(
|
||||
service.assetFileName(info, info.apkUrl!),
|
||||
'Lunaris-v2.0.2-linux-x64.tar.gz',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _MockDatabaseService implements DatabaseService {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/features/post/presentation/widgets/post_tags_panel.dart';
|
||||
@@ -26,7 +27,11 @@ void main() {
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Artist'), findsOneWidget);
|
||||
expect(find.text('Character'), findsOneWidget);
|
||||
|
||||
@@ -99,6 +99,83 @@ void main() {
|
||||
expect(find.text('new_tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('old external value does not restore deleted chip',
|
||||
(tester) async {
|
||||
String? changed;
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'old_tag keep_tag',
|
||||
onSubmitted: (_) {},
|
||||
onChanged: (value) {
|
||||
changed = value;
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.close_rounded).first);
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'old_tag keep_tag',
|
||||
onSubmitted: (_) {},
|
||||
onChanged: (value) {
|
||||
changed = value;
|
||||
},
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('old_tag'), findsNothing);
|
||||
expect(find.text('keep_tag'), findsOneWidget);
|
||||
expect(changed, 'keep_tag');
|
||||
});
|
||||
|
||||
testWidgets('focused draft is not replaced by stale external value',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'old_tag',
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'new_tag');
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'old_tag',
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('old_tag'), findsOneWidget);
|
||||
expect(find.text('new_tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('matching external draft does not become a chip while focused',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: '',
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'draft_tag');
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'draft_tag',
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(InputChip), findsNothing);
|
||||
expect(find.text('draft_tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('suggestion appends active tag as a chip', (tester) async {
|
||||
String? applied;
|
||||
await tester.pumpWidget(_Harness(
|
||||
@@ -118,8 +195,9 @@ void main() {
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'tou');
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('touhou'));
|
||||
await tester.pump();
|
||||
|
||||
@@ -127,6 +205,72 @@ void main() {
|
||||
expect(applied, 'touhou');
|
||||
});
|
||||
|
||||
testWidgets('stale suggestions are hidden for a newer active token',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'touhou',
|
||||
category: TagCategory.copyright,
|
||||
postCount: 1200,
|
||||
providerId: 'gelbooru',
|
||||
),
|
||||
],
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'tou');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('touhou'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'cat');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('touhou'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('suggestions overlay does not resize search bar', (tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'touhou',
|
||||
category: TagCategory.copyright,
|
||||
postCount: 1200,
|
||||
providerId: 'gelbooru',
|
||||
),
|
||||
TagSuggestion(
|
||||
name: 'tou_tail',
|
||||
category: TagCategory.general,
|
||||
postCount: 900,
|
||||
providerId: 'e621',
|
||||
),
|
||||
],
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height;
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'tou');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('touhou'), findsOneWidget);
|
||||
expect(find.text('tou_tail'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight);
|
||||
});
|
||||
|
||||
testWidgets('many chips stay in a single compact row', (tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: SizedBox(
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||
#include <webview_windows/webview_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
@@ -30,6 +29,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
||||
WebviewWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WebviewWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
media_kit_video
|
||||
url_launcher_windows
|
||||
volume_controller
|
||||
webview_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user