feat: platform service selection and media type filters for artist posts (v2.0.4)

This commit is contained in:
2026-09-03 18:36:23 +03:00
parent 5ae802f89c
commit 1f624fc020
9 changed files with 327 additions and 87 deletions
+7 -5
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.3-blueviolet?style=for-the-badge" alt="Version 2.0.3" />
<img src="https://img.shields.io/badge/Version-2.0.4-blueviolet?style=for-the-badge" alt="Version 2.0.4" />
<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" />
@@ -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`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.3';
const appBuildNumber = 25;
const appDisplayVersion = '2.0.4';
const appBuildNumber = 26;
const appReleaseChannel = 'Stable';
+9
View File
@@ -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',
@@ -47,6 +47,7 @@ abstract class TagMetadataProvider {
abstract class ArtistProvider {
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
@@ -55,6 +56,7 @@ abstract class ArtistProvider {
Future<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? services,
int page = 1,
int limit = 30,
});
+21 -31
View File
@@ -91,66 +91,56 @@ class KemonoProvider
@override
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
}) async {
final searchText = query?.trim() ?? '';
if (searchText.isNotEmpty) {
try {
final response = await _creatorDio.get<dynamic>(
_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<ArtistProfile> list;
try {
final response = await _creatorDio.get<dynamic>(
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<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? 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
+12 -3
View File
@@ -122,16 +122,23 @@ class PawchiveProvider
@override
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? 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<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? services,
int page = 1,
int limit = 30,
}) {
return listArtists(
service: service,
services: services,
query: query,
page: page,
limit: limit,
@@ -46,6 +46,7 @@ class ArtistPostsScreen extends ConsumerStatefulWidget {
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController();
final List<Post> _posts = [];
final Set<String> _selectedTypes = <String>{};
int _page = 0;
bool _loading = false;
bool _hasMore = true;
@@ -138,6 +139,100 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
}
}
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<ArtistPostsScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
return AdaptiveScaffold(
title: widget.artistName,
actions: [
@@ -166,45 +263,80 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
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);
},
],
),
);
}
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final _search = TextEditingController();
final _scroll = ScrollController();
final Set<String> _selectedServices = <String>{};
String? _providerId;
var _page = 1;
var _loading = false;
@@ -145,16 +146,38 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
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<ArtistsScreen> {
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<ArtistsScreen> {
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<ArtistsScreen> {
);
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<ContentProviderConfig> providers;
final String? selectedProviderId;
final Set<String> selectedServices;
final ValueChanged<String?> onProviderChanged;
final ValueChanged<String> 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),
],
],
),
),
],
),
),
+1 -1
View File
@@ -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"