diff --git a/README.md b/README.md
index 7b99484..df125b8 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -25,8 +25,10 @@
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
+- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
+- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
-- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
+- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
@@ -53,9 +55,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
-- **Android**: `Lunaris-v2.0.3.apk`
-- **Linux (x64)**: `Lunaris-v2.0.3-linux-x64.tar.gz`
-- **Windows**: `LunarisSetup-v2.0.3.exe` / `LunarisPortable-v2.0.3.zip`
+- **Android**: `Lunaris-v2.0.4.apk`
+- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
+- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
---
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index e4e8056..34f5d85 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '2.0.3';
-const appBuildNumber = 25;
+const appDisplayVersion = '2.0.4';
+const appBuildNumber = 26;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 444fe9f..6d3001d 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,13 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '2.0.4',
+ title: 'Platform service selection & media type filtering in artist posts',
+ bullets: [
+ 'Added multi-select platform filters (Patreon, Pixiv Fanbox, Fantia, Boosty, Discord) in the Artists tab.',
+ 'Added multi-select media type filter chips (Photos, Videos, GIFs) in artist posts with counts.',
+ 'Full-screen post pager respects active media type filters during swiping.',
+ ],
+ ),
LunarisChange(
version: '2.0.3',
title: 'Infinite artist pagination, favorite artists modal, and card layout polish',
diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart
index 3a8a12d..375ee15 100644
--- a/lib/backend/providers/content_provider.dart
+++ b/lib/backend/providers/content_provider.dart
@@ -47,6 +47,7 @@ abstract class TagMetadataProvider {
abstract class ArtistProvider {
Future> listArtists({
String? service,
+ List? services,
String? query,
int page = 1,
int limit = 30,
@@ -55,6 +56,7 @@ abstract class ArtistProvider {
Future> searchArtists(
String query, {
String? service,
+ List? services,
int page = 1,
int limit = 30,
});
diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart
index 0e4e381..57f3fce 100644
--- a/lib/backend/providers/kemono_provider.dart
+++ b/lib/backend/providers/kemono_provider.dart
@@ -91,66 +91,56 @@ class KemonoProvider
@override
Future> listArtists({
String? service,
+ List? services,
String? query,
int page = 1,
int limit = 30,
}) async {
- final searchText = query?.trim() ?? '';
- if (searchText.isNotEmpty) {
- try {
- final response = await _creatorDio.get(
- _creatorPath(),
- queryParameters: {
- 'keyword': searchText,
- 'page': page,
- 'itemsPerPage': limit.clamp(1, 100),
- },
- );
- 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.
- }
- }
+ final effectiveService =
+ services != null && services.length == 1 ? services.first : service;
+ List list;
try {
final response = await _creatorDio.get(
- service == null || service.isEmpty
+ effectiveService == null || effectiveService.isEmpty
? _creatorPath()
- : '${_creatorPath()}/$service',
+ : '${_creatorPath()}/$effectiveService',
queryParameters: {
'page': page,
'itemsPerPage': limit.clamp(1, 100),
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
},
);
- return _rankArtists(_artistsFromResponse(response.data), query ?? '');
+ list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) {
- return _listArtistsFromPublicApi(
- service: service,
+ list = await _listArtistsFromPublicApi(
+ service: effectiveService,
query: query,
page: page,
limit: limit,
);
}
+ if (services != null && services.isNotEmpty) {
+ final sSet = services.map((s) => s.toLowerCase()).toSet();
+ list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
+ }
+ return list;
}
@override
Future> searchArtists(
String query, {
String? service,
+ List? services,
int page = 1,
int limit = 30,
}) {
return listArtists(
- service: service, query: query, page: page, limit: limit);
+ service: service,
+ services: services,
+ query: query,
+ page: page,
+ limit: limit,
+ );
}
@override
diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart
index 223a666..774201d 100644
--- a/lib/backend/providers/pawchive_provider.dart
+++ b/lib/backend/providers/pawchive_provider.dart
@@ -122,16 +122,23 @@ class PawchiveProvider
@override
Future> listArtists({
String? service,
+ List? services,
String? query,
int page = 1,
int limit = 30,
}) async {
final allArtists = await _ensureArtistsLoaded();
final queryText = (query ?? '').trim().toLowerCase();
+ final activeServices = services != null && services.isNotEmpty
+ ? services.map((s) => s.trim().toLowerCase()).toSet()
+ : null;
final filtered = allArtists.where((artist) {
- final matchService = service == null ||
- service.isEmpty ||
- artist.service.toLowerCase() == service.toLowerCase();
+ final artistService = artist.service.toLowerCase();
+ final matchService = activeServices != null
+ ? activeServices.contains(artistService)
+ : service == null ||
+ service.isEmpty ||
+ artistService == service.toLowerCase();
if (!matchService) return false;
if (queryText.isEmpty) return true;
return artist.displayName.toLowerCase().contains(queryText) ||
@@ -147,11 +154,13 @@ class PawchiveProvider
Future> searchArtists(
String query, {
String? service,
+ List? services,
int page = 1,
int limit = 30,
}) {
return listArtists(
service: service,
+ services: services,
query: query,
page: page,
limit: limit,
diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart
index 30fbc69..5e3b0c5 100644
--- a/lib/features/artists/presentation/artist_posts_screen.dart
+++ b/lib/features/artists/presentation/artist_posts_screen.dart
@@ -46,6 +46,7 @@ class ArtistPostsScreen extends ConsumerStatefulWidget {
class _ArtistPostsScreenState extends ConsumerState {
final _scrollController = ScrollController();
final List _posts = [];
+ final Set _selectedTypes = {};
int _page = 0;
bool _loading = false;
bool _hasMore = true;
@@ -138,6 +139,100 @@ class _ArtistPostsScreenState extends ConsumerState {
}
}
+ bool _matchesTypeFilter(Post post) {
+ if (_selectedTypes.isEmpty) return true;
+ final isVid = MediaUrlSelector.isVideo(post);
+ final isGif = MediaUrlSelector.isGif(post);
+ final isPhoto = !isVid && !isGif;
+
+ if (_selectedTypes.contains('video') && isVid) return true;
+ if (_selectedTypes.contains('gif') && isGif) return true;
+ if (_selectedTypes.contains('photo') && isPhoto) return true;
+ return false;
+ }
+
+ Widget _buildMediaTypeFilters() {
+ final scheme = Theme.of(context).colorScheme;
+ final photoCount = _posts
+ .where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p))
+ .length;
+ final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
+ final gifCount = _posts.where(MediaUrlSelector.isGif).length;
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ FilterChip(
+ selected: _selectedTypes.isEmpty,
+ label: Text('Все (${_posts.length})'),
+ onSelected: (_) => setState(() => _selectedTypes.clear()),
+ ),
+ const SizedBox(width: 8),
+ FilterChip(
+ selected: _selectedTypes.contains('photo'),
+ avatar: Icon(Icons.photo_outlined,
+ size: 16,
+ color: _selectedTypes.contains('photo')
+ ? scheme.onPrimaryContainer
+ : scheme.onSurfaceVariant),
+ label: Text('Фото ($photoCount)'),
+ onSelected: (selected) {
+ setState(() {
+ if (selected) {
+ _selectedTypes.add('photo');
+ } else {
+ _selectedTypes.remove('photo');
+ }
+ });
+ },
+ ),
+ const SizedBox(width: 8),
+ FilterChip(
+ selected: _selectedTypes.contains('video'),
+ avatar: Icon(Icons.videocam_outlined,
+ size: 16,
+ color: _selectedTypes.contains('video')
+ ? scheme.onPrimaryContainer
+ : scheme.onSurfaceVariant),
+ label: Text('Видео ($videoCount)'),
+ onSelected: (selected) {
+ setState(() {
+ if (selected) {
+ _selectedTypes.add('video');
+ } else {
+ _selectedTypes.remove('video');
+ }
+ });
+ },
+ ),
+ const SizedBox(width: 8),
+ FilterChip(
+ selected: _selectedTypes.contains('gif'),
+ avatar: Icon(Icons.gif_rounded,
+ size: 20,
+ color: _selectedTypes.contains('gif')
+ ? scheme.onPrimaryContainer
+ : scheme.onSurfaceVariant),
+ label: Text('GIF ($gifCount)'),
+ onSelected: (selected) {
+ setState(() {
+ if (selected) {
+ _selectedTypes.add('gif');
+ } else {
+ _selectedTypes.remove('gif');
+ }
+ });
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final settings =
@@ -145,6 +240,8 @@ class _ArtistPostsScreenState extends ConsumerState {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? {};
+ final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
+
return AdaptiveScaffold(
title: widget.artistName,
actions: [
@@ -166,45 +263,80 @@ class _ArtistPostsScreenState extends ConsumerState {
title: 'No works',
message: 'This artist has no visible media yet.',
)
- : PostMasonryGrid(
- posts: _posts,
- controller: _scrollController,
- loading: _loading,
- 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: PostNavigationContext(
- currentPost: post,
- posts: _posts,
+ : Column(
+ children: [
+ _buildMediaTypeFilters(),
+ Expanded(
+ child: displayedPosts.isEmpty
+ ? Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.filter_alt_off_rounded,
+ size: 48),
+ const SizedBox(height: 12),
+ const Text(
+ 'Нет медиа по выбранным фильтрам',
+ style: TextStyle(
+ fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 8),
+ FilledButton.tonal(
+ onPressed: () => setState(
+ () => _selectedTypes.clear()),
+ child: const Text('Показать все'),
+ ),
+ ],
+ ),
+ ),
+ )
+ : PostMasonryGrid(
+ posts: displayedPosts,
+ controller: _scrollController,
+ loading: _loading,
+ 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: PostNavigationContext(
+ currentPost: post,
+ posts: displayedPosts,
+ ),
+ ),
+ 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);
+ ref.invalidate(favoritesControllerProvider);
+ },
+ ),
),
- ),
- 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);
- ref.invalidate(favoritesControllerProvider);
- },
+ ],
),
);
}
diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart
index eee8e9f..323694f 100644
--- a/lib/features/artists/presentation/artists_screen.dart
+++ b/lib/features/artists/presentation/artists_screen.dart
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
class _ArtistsScreenState extends ConsumerState {
final _search = TextEditingController();
final _scroll = ScrollController();
+ final Set _selectedServices = {};
String? _providerId;
var _page = 1;
var _loading = false;
@@ -145,16 +146,38 @@ class _ArtistsScreenState extends ConsumerState {
WidgetsBinding.instance
.addPostFrameCallback((_) => _refresh(items));
}
+ final displayedArtists = _selectedServices.isEmpty
+ ? _artists
+ : _artists
+ .where((a) =>
+ _selectedServices.contains(a.service.toLowerCase()))
+ .toList(growable: false);
+
return Column(
children: [
_ArtistsHeader(
searchController: _search,
providers: items,
selectedProviderId: _providerId,
+ selectedServices: _selectedServices,
onProviderChanged: (value) {
setState(() => _providerId = value);
_refresh(items);
},
+ onToggleService: (service) {
+ setState(() {
+ if (_selectedServices.contains(service)) {
+ _selectedServices.remove(service);
+ } else {
+ _selectedServices.add(service);
+ }
+ });
+ _refresh(items);
+ },
+ onClearServices: () {
+ setState(() => _selectedServices.clear());
+ _refresh(items);
+ },
onSearch: () => _refresh(items),
),
Expanded(
@@ -163,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState {
message: _friendlyArtistError(_error),
onRetry: () => _refresh(items),
)
- : _artists.isEmpty && !_loading
- ? const EmptyView(
- title: 'No artists',
- message: 'Try another name or provider.',
+ : displayedArtists.isEmpty && !_loading
+ ? Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.person_search_outlined,
+ size: 48),
+ const SizedBox(height: 12),
+ const Text(
+ 'Нет авторов по выбранным фильтрам',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 8),
+ FilledButton.tonal(
+ onPressed: () {
+ setState(
+ () => _selectedServices.clear());
+ _refresh(items);
+ },
+ child: const Text('Показать все сервисы'),
+ ),
+ ],
+ ),
+ ),
)
: LayoutBuilder(
builder: (context, constraints) {
@@ -189,12 +235,13 @@ class _ArtistsScreenState extends ConsumerState {
mainAxisExtent:
Responsive.isMobile(context) ? 104 : 116,
),
- itemCount: _artists.length + (_loading ? 1 : 0),
+ itemCount:
+ displayedArtists.length + (_loading ? 1 : 0),
itemBuilder: (context, index) {
- if (index >= _artists.length) {
+ if (index >= displayedArtists.length) {
return const _ArtistSkeletonCard();
}
- final artist = _artists[index];
+ final artist = displayedArtists[index];
final isFav = favoriteKeys.contains(
'${artist.providerId}:${artist.service}:${artist.id}');
return _ArtistCard(
@@ -250,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState {
);
final next = await provider.searchArtists(
_search.text.trim(),
+ services: _selectedServices.isEmpty ? null : _selectedServices.toList(),
page: _page,
limit: 30,
);
@@ -317,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget {
required this.searchController,
required this.providers,
required this.selectedProviderId,
+ required this.selectedServices,
required this.onProviderChanged,
+ required this.onToggleService,
+ required this.onClearServices,
required this.onSearch,
});
final TextEditingController searchController;
final List providers;
final String? selectedProviderId;
+ final Set selectedServices;
final ValueChanged onProviderChanged;
+ final ValueChanged onToggleService;
+ final VoidCallback onClearServices;
final VoidCallback onSearch;
+ static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord'];
+
+ String _formatServiceName(String s) {
+ switch (s.toLowerCase()) {
+ case 'patreon':
+ return 'Patreon';
+ case 'fanbox':
+ return 'Pixiv Fanbox';
+ case 'fantia':
+ return 'Fantia';
+ case 'boosty':
+ return 'Boosty';
+ case 'discord':
+ return 'Discord';
+ default:
+ return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
+ }
+ }
+
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -382,6 +455,29 @@ class _ArtistsHeader extends StatelessWidget {
},
),
),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 36,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ children: [
+ FilterChip(
+ selected: selectedServices.isEmpty,
+ label: const Text('Все платформы'),
+ onSelected: (_) => onClearServices(),
+ ),
+ const SizedBox(width: 8),
+ for (final service in _availableServices) ...[
+ FilterChip(
+ selected: selectedServices.contains(service),
+ label: Text(_formatServiceName(service)),
+ onSelected: (_) => onToggleService(service),
+ ),
+ const SizedBox(width: 8),
+ ],
+ ],
+ ),
+ ),
],
),
),
diff --git a/pubspec.yaml b/pubspec.yaml
index 698b2b0..e86a194 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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.3+25
+version: 2.0.4+26
environment:
sdk: ">=3.3.0 <4.0.0"