Compare commits

..
6 Commits
Author SHA1 Message Date
RarDog 5b2c8eb53e fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:09:58 +03:00
RarDog d7d9cad423 fix(desktop): smooth sidebar hover animation without text jitter and remove mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:58:49 +03:00
RarDog 7d3c4f964a feat: desktop UI redesign and comprehensive PC hotkeys (v3.6.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:44:53 +03:00
RarDog 877a044ff3 feat(linux): add build_linux_appimage.sh script for AppImage packaging 2026-09-05 17:15:21 +03:00
RarDog 28a63483da feat: persist last active tab and preserve per-tab navigation state
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 15:59:02 +03:00
RarDog af00cb8179 fix: resolve video misclassification when filename contains 'audio' and remove legacy Kemono/Coomer providers
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 15:35:41 +03:00
37 changed files with 2404 additions and 1243 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.3" /></a>
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></a>
<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%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
<a href="https://github.com/RarDog/Prisma/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
+1 -1
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.3" /></a>
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></a>
<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%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
<a href="https://github.com/RarDog/Prisma/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
+39 -38
View File
@@ -4,32 +4,33 @@
<application
android:label="Prisma"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
VERSION=$(grep 'version:' pubspec.yaml | head -n 1 | awk '{print $2}' | cut -d'+' -f1)
if [ -z "$VERSION" ]; then
VERSION="3.5.5"
fi
echo "Building AppImage for Prisma v${VERSION}..."
BUNDLE_DIR="${ROOT_DIR}/build/linux/x64/release/bundle"
if [ ! -f "${BUNDLE_DIR}/gel_rule_app" ]; then
echo "Release bundle not found. Running 'flutter build linux --release'..."
flutter build linux --release
fi
APP_DIR="${ROOT_DIR}/build/AppDir"
OUTPUT_DIR="${ROOT_DIR}/build"
APPIMAGE_NAME="Prisma-v${VERSION}-linux-x86_64.AppImage"
OUTPUT_APPIMAGE="${OUTPUT_DIR}/${APPIMAGE_NAME}"
# Ensure appimagetool is available
APPIMAGETOOL=""
if command -v appimagetool >/dev/null 2>&1; then
APPIMAGETOOL="appimagetool"
elif [ -f "${ROOT_DIR}/build/tools/appimagetool" ]; then
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
else
echo "Downloading appimagetool..."
mkdir -p "${ROOT_DIR}/build/tools"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "${ROOT_DIR}/build/tools/appimagetool"
chmod +x "${ROOT_DIR}/build/tools/appimagetool"
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
fi
echo "Preparing AppDir at ${APP_DIR}..."
rm -rf "${APP_DIR}"
mkdir -p "${APP_DIR}"
# Copy bundle contents
cp -r "${BUNDLE_DIR}"/* "${APP_DIR}/"
# AppRun script
cat << 'EOF' > "${APP_DIR}/AppRun"
#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")"
export PATH="${HERE}/usr/bin:${PATH}"
export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/usr/lib:${LD_LIBRARY_PATH}"
exec "${HERE}/gel_rule_app" "$@"
EOF
chmod +x "${APP_DIR}/AppRun"
# Desktop file
cat << 'EOF' > "${APP_DIR}/prisma.desktop"
[Desktop Entry]
Name=Prisma
GenericName=Booru Client
Comment=Prisma - Modern Booru & Art Client
Exec=gel_rule_app %u
Icon=prisma
Terminal=false
Type=Application
Categories=Network;Graphics;
StartupWMClass=com.example.gel_rule_app
EOF
mkdir -p "${APP_DIR}/usr/share/applications"
cp "${APP_DIR}/prisma.desktop" "${APP_DIR}/usr/share/applications/prisma.desktop"
# Icons
ICON_SRC="${ROOT_DIR}/macos/Runner/Assets.xcassets/AppIcon.appiconset"
if [ -d "${ICON_SRC}" ]; then
for size in 16 32 64 128 256 512 1024; do
if [ -f "${ICON_SRC}/app_icon_${size}.png" ]; then
mkdir -p "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps"
cp "${ICON_SRC}/app_icon_${size}.png" "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps/prisma.png"
fi
done
cp "${ICON_SRC}/app_icon_512.png" "${APP_DIR}/prisma.png"
fi
# Build AppImage
echo "Generating AppImage with ${APPIMAGETOOL}..."
ARCH=x86_64 "${APPIMAGETOOL}" -n "${APP_DIR}" "${OUTPUT_APPIMAGE}"
echo "AppImage created successfully: ${OUTPUT_APPIMAGE}"
ls -lh "${OUTPUT_APPIMAGE}"
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../backend/models/post.dart';
import '../features/post/presentation/post_details_screen.dart';
import '../features/post/presentation/similar_posts_screen.dart';
class AppNavigator {
const AppNavigator._();
static void openPost(
BuildContext context, {
required Post post,
List<Post>? postsList,
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
postsList: postsList,
),
),
);
}
static void openSimilarPosts(
BuildContext context, {
required Post post,
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => SimilarPostsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
),
),
);
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '3.5.3';
const appBuildNumber = 79;
const appDisplayVersion = '3.5.5';
const appBuildNumber = 81;
const appReleaseChannel = 'Stable';
+49
View File
@@ -1,4 +1,53 @@
const prismaChangelog = [
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange(
version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
bullets: [
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
],
),
PrismaChange(
version: '3.6.0',
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
bullets: [
'Горячие клавиши для всех вкладок: переключение разделов по Ctrl+1..8 (и Alt+1..8), циклический переход Ctrl+Tab / Ctrl+Shift+Tab, поиск Ctrl+F, обновление Ctrl+R / F5.',
'Хоткеи видеоплеера: пауза/воспроизведение (Space, K), плавная перемотка на 5с (←/→, J/L) и 15с (Shift+←/→) с визуальными индикаторами, регулировка громкости ↑/↓, выключение звука M, полный экран F.',
'Хоткеи просмотра фото и галереи: масштабирование клавишами + / - / 0 (сброс в 100%), переключение страниц и фото в постах стрелками ← / →, быстрое сохранение Ctrl+S.',
'Окно-шпаргалка горячих клавиш: интерактивное модальное окно со всеми клавиатурными комбинациями по нажатию ? или F1, а также из меню панели.',
'Редизайн боковой панели ПК: боковое меню в стиле Liquid Glass с эффектом матового стекла, логотипом Lunaris, бейджами хоткеев, закреплением панели и быстрыми действиями.',
'Редизайн вкладок ленты: стильный сегментированный бар выбора периодов (Все время, 24ч, Неделя, Месяц, Год) с анимированными иконками и неоновые чипы источников.',
],
),
PrismaChange(
version: '3.5.5',
title: 'Сохранение последней вкладки и состояния навигации между разделами',
bullets: [
'Сохранение последней вкладки: приложение запоминает активный раздел при выходе или закрытии и автоматически открывает его при следующем запуске.',
'Независимая история и сохранение места: при переключении между вкладками состояние каждого раздела полностью сохраняется. Например, если открыть публикацию или карточку у артиста и переключиться в поиск или ленту, при возврате обратно вы останетесь ровно на том же посте.',
'Бесшовные переходы между ветками навигации: улучшена обработка системной кнопки «Назад», переход по разделам стал еще быстрее и плавнее.',
],
),
PrismaChange(
version: '3.5.4',
title: 'Исправление распознавания MP4-видео и отключение устаревших сервисов Kemono/Coomer',
bullets: [
'Устранена ошибка классификации видео: файлы MP4, содержащие слово «audio» в имени файла или параметрах запроса, больше не распознаются как аудио и корректно воспроизводятся как полноценное видео.',
'Точное определение расширений: внедрён строгий парсинг расширений файлов, исключающий ложные срабатывания при определении типа медиа.',
'Удаление устаревших провайдеров Kemono и Coomer: окончательно удалены неработающие сервисы Kemono и Coomer из списка провайдеров и настроек (основным источником артистов является Pawchive).',
],
),
PrismaChange(
version: '3.5.3',
title: 'Поддержка аудиозаписей (MP3/WAV/FLAC) и исправление загрузки медиа у авторов',
+153 -98
View File
@@ -21,116 +21,171 @@ import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart';
import '../shared/widgets/app_shell.dart';
final branchNavKeys = List.generate(8, (_) => GlobalKey<NavigatorState>());
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppShell(
navigationShell: navigationShell,
child: navigationShell,
),
branches: [
StatefulShellBranch(
navigatorKey: branchNavKeys[0],
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => _transitionPage(
state,
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[1],
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[2],
routes: [
GoRoute(
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[3],
routes: [
GoRoute(
path: '/viewed',
builder: (context, state) => const ViewedScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[4],
routes: [
GoRoute(
path: '/collections',
builder: (context, state) => const CollectionsScreen(),
routes: [
GoRoute(
path: ':collectionId',
builder: (context, state) => CollectionDetailsScreen(
collectionId: state.pathParameters['collectionId']!,
),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[5],
routes: [
GoRoute(
path: '/artists',
builder: (context, state) => const ArtistsScreen(),
routes: [
GoRoute(
path: ':providerId/:service/:artistId',
builder: (context, state) => ArtistPostsScreen(
providerId: state.pathParameters['providerId']!,
service: state.pathParameters['service']!,
artistId: state.pathParameters['artistId']!,
artistName: state.uri.queryParameters['name'] ??
state.pathParameters['artistId']!,
),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[6],
routes: [
GoRoute(
path: '/providers',
builder: (context, state) => const ProvidersScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => ProviderFormScreen(
initialConfig: state.extra is ContentProviderConfig
? state.extra! as ContentProviderConfig
: null,
),
),
GoRoute(
path: 'check',
builder: (context, state) => const ProviderCheckScreen(),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[7],
routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
GoRoute(
path: 'cache',
builder: (context, state) => const CacheManagerScreen(),
),
],
),
],
),
],
),
GoRoute(
path: '/post/:providerId/:postId',
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,
);
},
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => _transitionPage(
state,
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
),
),
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: '/post/:providerId/:postId',
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',
path: 'similar',
builder: (context, state) => SimilarPostsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: state.extra is Post ? state.extra! as Post : null,
),
),
GoRoute(
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
),
GoRoute(
path: '/viewed',
builder: (context, state) => const ViewedScreen(),
),
GoRoute(
path: '/collections',
builder: (context, state) => const CollectionsScreen(),
),
GoRoute(
path: '/collections/:collectionId',
builder: (context, state) => CollectionDetailsScreen(
collectionId: state.pathParameters['collectionId']!,
),
),
GoRoute(
path: '/artists',
builder: (context, state) => const ArtistsScreen(),
),
GoRoute(
path: '/artists/:providerId/:service/:artistId',
builder: (context, state) => ArtistPostsScreen(
providerId: state.pathParameters['providerId']!,
service: state.pathParameters['service']!,
artistId: state.pathParameters['artistId']!,
artistName: state.uri.queryParameters['name'] ??
state.pathParameters['artistId']!,
),
),
GoRoute(
path: '/providers',
builder: (context, state) => const ProvidersScreen(),
),
GoRoute(
path: '/providers/new',
builder: (context, state) => ProviderFormScreen(
initialConfig: state.extra is ContentProviderConfig
? state.extra! as ContentProviderConfig
: null,
),
),
GoRoute(
path: '/providers/check',
builder: (context, state) => const ProviderCheckScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
GoRoute(
path: '/settings/cache',
builder: (context, state) => const CacheManagerScreen(),
),
],
),
],
-658
View File
@@ -1,658 +0,0 @@
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart';
import '../models/artist_tag.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 '../services/cloud_link_extractor.dart';
import 'content_provider.dart';
class KemonoProvider
implements ContentProvider, ArtistProvider, CommentProvider {
KemonoProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
Map<String, String> queryParameters = const {},
}) : _dio = dioClient.dio,
_creatorDio = Dio(
BaseOptions(
baseUrl: queryParameters['creator_api_base'] ??
'https://kemono-api.mbaharip.com',
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
headers: const {
'Accept': 'application/json',
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
),
);
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
final Dio _creatorDio;
bool get _isCoomer =>
id.toLowerCase().contains('coomer') ||
baseUrl.toLowerCase().contains('coomer');
@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 => null;
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
await _creatorDio.get<dynamic>(
_creatorPath(),
queryParameters: {'page': 1, 'itemsPerPage': 1},
);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
);
}
}
@override
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
}) async {
final effectiveService =
services != null && services.length == 1 ? services.first : service;
List<ArtistProfile> list;
try {
final response = await _creatorDio.get<dynamic>(
effectiveService == null || effectiveService.isEmpty
? _creatorPath()
: '${_creatorPath()}/$effectiveService',
queryParameters: {
'page': page,
'itemsPerPage': limit.clamp(1, 100),
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
},
);
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) {
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,
services: services,
query: query,
page: page,
limit: limit,
);
}
@override
Future<List<Post>> getArtistPosts({
required ArtistWorkQuery query,
int page = 0,
int limit = 50,
}) async {
Object? lastError;
final paths = [
'/api/v1/${query.service}/user/${query.artistId}/posts',
'/api/v1/${query.service}/user/${query.artistId}',
];
for (final apiBase in _apiBaseCandidates()) {
for (final path in paths) {
try {
final response = await _getFromApiBase<dynamic>(
apiBase,
path,
queryParameters: {'o': page * limit},
);
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,
mediaBaseUrl: apiBase,
));
}
if (mapped.isNotEmpty || posts.isEmpty) {
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false);
}
} catch (error) {
lastError = error;
}
}
}
throw StateError(
'$name artist works are unavailable right now. Try again later. '
'${_shortError(lastError)}',
);
}
@override
Future<List<PostComment>> getArtistPostComments(
String service,
String artistId,
String postId,
) async {
Object? lastError;
final path = '/api/v1/$service/user/$artistId/post/$postId/comments';
for (final apiBase in _apiBaseCandidates()) {
try {
final response = await _getFromApiBase<dynamic>(apiBase, path);
return _commentsFromResponse(response.data, postId);
} catch (error) {
lastError = error;
}
}
throw StateError(
'$name comments are unavailable right now. ${_shortError(lastError)}',
);
}
@override
Future<List<PostComment>> getComments(String postId) async {
final parts = postId.split(':');
if (parts.length < 4) return const [];
return getArtistPostComments(parts[0], parts[1], parts[2]);
}
@override
Future<List<ArtistTag>> getArtistTags(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async {
final path = '/api/v1/$service/user/$artistId/announcements';
for (final apiBase in _apiBaseCandidates()) {
try {
final response = await _getFromApiBase<dynamic>(apiBase, path);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) =>
ArtistAnnouncement.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
}
return const [];
}
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
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 rawAvatar = (json['avatar'] ??
json['icon'] ??
json['image'] ??
json['avatar_url'] ??
'')
.toString();
return ArtistProfile(
id: artistId,
providerId: id,
service: service,
name: name,
displayName: name,
avatarUrl: _absoluteUrl(rawAvatar) ?? _iconUrl(service, artistId),
updatedAt: _dateFromAny(json['updated']),
postCount: (json['post_count'] as num?)?.toInt(),
url: '$baseUrl/$service/user/$artistId',
);
})
.where((artist) => artist.id.isNotEmpty)
.toList(growable: false);
}
Future<List<ArtistProfile>> _listArtistsFromPublicApi({
String? service,
String? query,
required int page,
required int limit,
}) async {
Object? lastError;
dynamic data;
for (final apiBase in _apiBaseCandidates()) {
try {
final response =
await _getFromApiBase<dynamic>(apiBase, '/api/v1/creators.txt');
data = response.data;
break;
} catch (error) {
lastError = error;
}
}
if (data == null) {
throw StateError(
'$name artist index is unavailable right now. ${_shortError(lastError)}',
);
}
final queryText = (query ?? '').trim().toLowerCase();
final offset = (page - 1).clamp(0, 999999) * limit;
final items = _artistsFromResponse({'data': data})
.where((artist) =>
(service == null || service.isEmpty || artist.service == service) &&
(queryText.isEmpty ||
artist.displayName.toLowerCase().contains(queryText) ||
artist.id.toLowerCase().contains(queryText)))
.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) {
if (data is List) return data;
if (data is Map) {
final results = data['results'];
if (results is List) return results;
final posts = data['posts'];
if (posts is List) return posts;
if (posts is Map) return _postItems(posts);
final dataItems = data['data'];
if (dataItems is List) return dataItems;
}
return const [];
}
List<Post> _postsFromArtistPost(
Map<String, dynamic> json, {
required ArtistWorkQuery query,
required String mediaBaseUrl,
}) {
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 postSource =
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
final rawContent = (json['content'] ?? '').toString();
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
final cloudLinks = CloudLinkExtractor.extractLinks(
content: rawContent,
source: postSource,
embed: embed,
);
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
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));
}
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
// or text-only posts where author wrote an announcement, status or note
if (files.isEmpty &&
(cloudLinks.isNotEmpty ||
cleanDesc.isNotEmpty ||
rawContent.trim().isNotEmpty ||
title.trim().isNotEmpty)) {
final stableId = '${query.service}:${query.artistId}:$postId:0';
final firstStreamable =
cloudLinks.where((l) => l.isStreamable).firstOrNull;
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
final isTextOnly = cloudLinks.isEmpty;
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
if (!isTextOnly) 'cloud_mirror' else 'text_post',
];
return [
Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: '',
sampleUrl: '',
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: firstStreamable != null
? 'video'
: (isTextOnly ? 'text' : 'link'),
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty)
'description': [cleanDesc]
else if (rawContent.trim().isNotEmpty)
'description': [rawContent.trim()],
},
),
];
}
var index = 0;
return files
.map((file) {
final url = _mediaUrl(file, mediaBaseUrl: mediaBaseUrl);
final fileName =
(file['name'] ?? Uri.tryParse(url)?.pathSegments.last ?? '')
.toString();
final type = _fileType('$fileName $url');
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: url,
sampleUrl: url,
fileUrl: url,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: type,
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
},
);
})
.where((post) => post.fileUrl.isNotEmpty)
.toList(growable: false);
}
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['author'] ??
json['username'] ??
json['user'] ??
json['creator_name'] ??
'user')
.toString(),
body: (json['content'] ?? json['body'] ?? json['comment'] ?? '')
.toString(),
createdAt: _dateFromAny(json['added']) ??
_dateFromAny(json['created_at']) ??
DateTime.fromMillisecondsSinceEpoch(0),
);
})
.where((comment) => comment.body.trim().isNotEmpty)
.toList(growable: false);
}
String _mediaUrl(
Map<String, dynamic> file, {
required String mediaBaseUrl,
}) {
final path = (file['path'] ?? file['url'] ?? '').toString();
if (path.isEmpty) return '';
final uri = Uri.tryParse(path);
if (uri != null && uri.hasScheme) return path;
if (path.startsWith('/data/')) {
return '$mediaBaseUrl$path';
}
if (path.startsWith('/')) {
return '$mediaBaseUrl/data$path';
}
return '$mediaBaseUrl/data/$path';
}
String? _iconUrl(String service, String artistId) {
if (service.isEmpty || artistId.isEmpty) return null;
final host = _isCoomer ? 'https://img.coomer.su' : 'https://img.kemono.su';
return '$host/icons/$service/$artistId';
}
String? _absoluteUrl(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
final uri = Uri.tryParse(trimmed);
if (uri != null && uri.hasScheme) return trimmed;
if (trimmed.startsWith('/')) return '$baseUrl$trimmed';
return '$baseUrl/$trimmed';
}
List<String> _apiBaseCandidates() {
final candidates = <String>[
baseUrl,
if (_isCoomer) ...[
'https://coomer.st',
'https://coomer.su',
] else ...[
'https://kemono.cr',
'https://kemono.su',
],
];
return candidates.map(_trimTrailingSlash).toSet().toList(growable: false);
}
Future<Response<T>> _getFromApiBase<T>(
String apiBase,
String path, {
Map<String, Object?> queryParameters = const {},
}) {
if (_trimTrailingSlash(apiBase) == _trimTrailingSlash(baseUrl)) {
return _dio.get<T>(path, queryParameters: queryParameters);
}
final dio = Dio(
BaseOptions(
baseUrl: apiBase,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 25),
headers: const {
'Accept': 'application/json',
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
),
);
return dio.get<T>(path, queryParameters: queryParameters);
}
String _trimTrailingSlash(String value) =>
value.endsWith('/') ? value.substring(0, value.length - 1) : value;
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.trim();
if (lower.endsWith('.webm') ||
lower.endsWith('.mp4') ||
lower.endsWith('.mov') ||
lower.endsWith('.mkv') ||
lower.endsWith('.avi') ||
lower.endsWith('.flv') ||
lower.endsWith('.wmv') ||
lower.endsWith('.m4v') ||
lower.endsWith('.ts')) {
return 'video';
}
if (lower.endsWith('.mp3') ||
lower.endsWith('.m4a') ||
lower.endsWith('.wav') ||
lower.endsWith('.ogg') ||
lower.endsWith('.flac') ||
lower.endsWith('.aac') ||
lower.endsWith('.opus') ||
lower.endsWith('.wma')) {
return 'audio';
}
if (lower.endsWith('.gif')) return 'gif';
if (lower.endsWith('.swf')) return 'swf';
if (lower.endsWith('.zip') ||
lower.endsWith('.rar') ||
lower.endsWith('.7z') ||
lower.endsWith('.tar') ||
lower.endsWith('.gz') ||
lower.endsWith('.pdf') ||
lower.endsWith('.txt')) {
return 'archive';
}
if (lower.endsWith('.png') ||
lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.webp') ||
lower.endsWith('.avif') ||
lower.endsWith('.bmp') ||
lower.endsWith('.heic') ||
lower.endsWith('.tiff')) {
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());
}
}
+66 -40
View File
@@ -481,7 +481,7 @@ class PawchiveProvider
for (final f in files) {
final p = (f['path'] ?? '').toString();
final n = (f['name'] ?? '').toString();
if (_isPhotoType('$n $p') && p.isNotEmpty) {
if (_isPhotoType(p, n) && p.isNotEmpty) {
postCoverUrl = _thumbnailUrl(p);
break;
}
@@ -494,8 +494,8 @@ class PawchiveProvider
final fileName = (fileMap['name'] ?? '').toString();
final previewOnly = fileMap['preview_only'] == true;
final type = _fileType('$fileName $rawPath');
final isPhoto = _isPhotoType('$fileName $rawPath');
final type = _fileTypeFor(rawPath, fileName);
final isPhoto = type == 'photo' || type == 'gif';
// img.pawchive.pw/thumbnail only generates thumbnails for images/gifs.
// Non-image files (audio, video, archive) return 404 from thumbnail server.
@@ -626,55 +626,81 @@ class PawchiveProvider
return '${message.substring(0, 160)}...';
}
String _extractExtension(String value) {
if (value.isEmpty) return '';
final clean = value.split('?').first.split('#').first.trim();
final dot = clean.lastIndexOf('.');
if (dot != -1 && dot < clean.length - 1) {
final ext = clean.substring(dot).toLowerCase();
if (ext.length <= 6) return ext;
}
return '';
}
String _fileType(String value) {
final lower = value.toLowerCase().split('?').first.trim();
if (lower.endsWith('.webm') ||
lower.endsWith('.mp4') ||
lower.endsWith('.mov') ||
lower.endsWith('.mkv') ||
lower.endsWith('.avi') ||
lower.endsWith('.flv') ||
lower.endsWith('.wmv') ||
lower.endsWith('.m4v') ||
lower.endsWith('.ts')) {
final ext = _extractExtension(value);
if (const {
'.webm',
'.mp4',
'.mov',
'.mkv',
'.avi',
'.flv',
'.wmv',
'.m4v',
'.ts',
}.contains(ext)) {
return 'video';
}
if (lower.endsWith('.mp3') ||
lower.endsWith('.m4a') ||
lower.endsWith('.wav') ||
lower.endsWith('.ogg') ||
lower.endsWith('.flac') ||
lower.endsWith('.aac') ||
lower.endsWith('.opus') ||
lower.endsWith('.wma')) {
if (const {
'.mp3',
'.m4a',
'.wav',
'.ogg',
'.flac',
'.aac',
'.opus',
'.wma',
}.contains(ext)) {
return 'audio';
}
if (lower.endsWith('.gif')) return 'gif';
if (lower.endsWith('.swf')) return 'swf';
if (lower.endsWith('.zip') ||
lower.endsWith('.rar') ||
lower.endsWith('.7z') ||
lower.endsWith('.tar') ||
lower.endsWith('.gz') ||
lower.endsWith('.pdf') ||
lower.endsWith('.txt')) {
if (ext == '.gif') return 'gif';
if (ext == '.swf') return 'swf';
if (const {
'.zip',
'.rar',
'.7z',
'.tar',
'.gz',
'.pdf',
'.txt',
}.contains(ext)) {
return 'archive';
}
if (lower.endsWith('.png') ||
lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.webp') ||
lower.endsWith('.avif') ||
lower.endsWith('.bmp') ||
lower.endsWith('.heic') ||
lower.endsWith('.tiff')) {
if (const {
'.png',
'.jpg',
'.jpeg',
'.webp',
'.avif',
'.bmp',
'.heic',
'.tiff',
}.contains(ext)) {
return 'photo';
}
return 'unknown';
}
bool _isPhotoType(String value) {
final type = _fileType(value);
String _fileTypeFor(String path, String fileName) {
final extName = _extractExtension(fileName);
final extPath = _extractExtension(path);
final ext = extName.isNotEmpty ? extName : extPath;
return _fileType(ext);
}
bool _isPhotoType(String path, [String fileName = '']) {
final type = _fileTypeFor(path, fileName);
return type == 'photo' || type == 'gif';
}
@@ -5,7 +5,6 @@ import 'custom_provider.dart';
import 'danbooru_provider.dart';
import 'e621_provider.dart';
import 'gelbooru_provider.dart';
import 'kemono_provider.dart';
import 'moebooru_provider.dart';
import 'pawchive_provider.dart';
import 'realbooru_html_provider.dart';
@@ -82,15 +81,6 @@ class ProviderFactory {
dioClient: client,
queryParameters: queryParameters,
);
case 'kemono':
case 'coomer':
return KemonoProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
);
case 'pawchive':
return PawchiveProvider(
id: config.id,
+8 -3
View File
@@ -730,18 +730,23 @@ class ProviderManager {
static bool _isArtistConfig(ContentProviderConfig config) {
final type = config.apiType.toLowerCase();
return type == 'kemono' || type == 'coomer' || type == 'pawchive';
return type == 'pawchive';
}
static bool _isFeedConfig(ContentProviderConfig config) {
final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
return type != 'pawchive';
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
final id = config.id.toLowerCase();
final type = config.apiType.toLowerCase();
return id == 'cosbooru' || type == 'realbooru';
return id == 'cosbooru' ||
type == 'realbooru' ||
type == 'kemono' ||
type == 'coomer' ||
id == 'kemono' ||
id == 'coomer';
}
}
@@ -64,8 +64,10 @@ class AppSettings {
this.downloadPathTemplate = '{Artist}/{ID}',
this.searchHistoryLimit = 500,
this.tagCacheLimit = 5000,
this.lastActiveLocation = '/',
});
final String lastActiveLocation;
final List<String> favoriteArtists;
final List<String> pawchiveAccounts;
final bool pawchiveBidirectionalSync;
@@ -165,6 +167,7 @@ class AppSettings {
lastFeedRating: null,
searchHistoryLimit: 500,
tagCacheLimit: 5000,
lastActiveLocation: '/',
);
AppSettings copyWith({
@@ -220,8 +223,10 @@ class AppSettings {
String? downloadPathTemplate,
int? searchHistoryLimit,
int? tagCacheLimit,
String? lastActiveLocation,
}) {
return AppSettings(
lastActiveLocation: lastActiveLocation ?? this.lastActiveLocation,
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
pawchiveBidirectionalSync:
@@ -336,6 +341,7 @@ class AppSettings {
'downloadPathTemplate': downloadPathTemplate,
'searchHistoryLimit': searchHistoryLimit,
'tagCacheLimit': tagCacheLimit,
'lastActiveLocation': lastActiveLocation,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -455,6 +461,8 @@ class AppSettings {
defaults.searchHistoryLimit,
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
defaults.tagCacheLimit,
lastActiveLocation: (json['lastActiveLocation'] as String?) ??
defaults.lastActiveLocation,
);
List<PawchiveAccount> get parsedPawchiveAccounts {
@@ -545,6 +553,14 @@ class SettingsService {
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
}
Future<Result<void>> saveLastActiveLocation(String location) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
if (settings.lastActiveLocation == location) return const Success(null);
return updateSettings(settings.copyWith(lastActiveLocation: location));
}
Future<Result<void>> saveCacheSettings({
required int ttlHours,
required int maxItems,
+97 -43
View File
@@ -100,61 +100,115 @@ class MediaUrlSelector {
.toList(growable: false);
}
static const _videoExtensions = {
'.mp4',
'.webm',
'.mov',
'.mkv',
'.avi',
'.flv',
'.wmv',
'.m4v',
'.ts',
};
static const _audioExtensions = {
'.mp3',
'.m4a',
'.wav',
'.ogg',
'.flac',
'.aac',
'.opus',
'.wma',
};
static String _extractExtension(String url) {
if (url.isEmpty) return '';
try {
final uri = Uri.parse(url);
final filenameParam =
uri.queryParameters['f'] ?? uri.queryParameters['filename'];
if (filenameParam != null && filenameParam.isNotEmpty) {
final cleanParam = filenameParam.split('?').first.split('#').first;
final dot = cleanParam.lastIndexOf('.');
if (dot != -1 && dot < cleanParam.length - 1) {
final ext = cleanParam.substring(dot).toLowerCase();
if (ext.length <= 6) return ext;
}
}
final path = uri.path;
final dot = path.lastIndexOf('.');
if (dot != -1 && dot < path.length - 1) {
final ext = path.substring(dot).toLowerCase();
if (ext.length <= 6) return ext;
}
} catch (_) {
// Uri parsing failed, fallback below
}
final clean = url.split('?').first.split('#').first;
final dot = clean.lastIndexOf('.');
if (dot != -1 && dot < clean.length - 1) {
final ext = clean.substring(dot).toLowerCase();
if (ext.length <= 6) return ext;
}
return '';
}
static bool _isAudio(Post post) {
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('audio') ||
value.contains('.mp3') ||
value.contains('.m4a') ||
value.contains('.wav') ||
value.contains('.ogg') ||
value.contains('.flac') ||
value.contains('.aac') ||
value.contains('.opus') ||
value.contains('.wma');
final type = post.fileType.toLowerCase().trim();
if (type == 'audio') return true;
if (type == 'video' || type == 'photo' || type == 'archive' || type == 'gif') {
return false;
}
final fileExt = _extractExtension(post.fileUrl);
if (_audioExtensions.contains(fileExt)) return true;
if (_videoExtensions.contains(fileExt)) return false;
final sampleExt = _extractExtension(post.sampleUrl);
if (_audioExtensions.contains(sampleExt)) return true;
if (_videoExtensions.contains(sampleExt)) return false;
return false;
}
static bool _isVideo(Post post) {
if (_isAudio(post)) return false;
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('video') ||
value.contains('.webm') ||
value.contains('.mp4') ||
value.contains('.mov') ||
value.contains('.mkv') ||
value.contains('.avi') ||
value.contains('.flv') ||
value.contains('.wmv') ||
value.contains('.m4v') ||
value.contains('.ts');
final type = post.fileType.toLowerCase().trim();
if (type == 'video') return true;
if (type == 'audio' || type == 'photo' || type == 'archive' || type == 'gif') {
return false;
}
final fileExt = _extractExtension(post.fileUrl);
if (_videoExtensions.contains(fileExt)) return true;
if (_audioExtensions.contains(fileExt)) return false;
final sampleExt = _extractExtension(post.sampleUrl);
if (_videoExtensions.contains(sampleExt)) return true;
if (_audioExtensions.contains(sampleExt)) return false;
return false;
}
static bool _isGif(Post post) {
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('gif') || value.contains('.gif');
final type = post.fileType.toLowerCase().trim();
if (type == 'gif') return true;
final fileExt = _extractExtension(post.fileUrl);
if (fileExt == '.gif') return true;
final sampleExt = _extractExtension(post.sampleUrl);
return sampleExt == '.gif';
}
static bool _looksLikeVideoUrl(String url) {
final value = url.toLowerCase();
return value.contains('.webm') ||
value.contains('.mp4') ||
value.contains('.mov') ||
value.contains('.mkv') ||
value.contains('.avi') ||
value.contains('.flv') ||
value.contains('.wmv') ||
value.contains('.m4v') ||
value.contains('.ts');
final ext = _extractExtension(url);
return _videoExtensions.contains(ext);
}
static bool _looksLikeAudioUrl(String url) {
final value = url.toLowerCase();
return value.contains('.mp3') ||
value.contains('.m4a') ||
value.contains('.wav') ||
value.contains('.ogg') ||
value.contains('.flac') ||
value.contains('.aac') ||
value.contains('.opus') ||
value.contains('.wma');
final ext = _extractExtension(url);
return _audioExtensions.contains(ext);
}
}
@@ -3,10 +3,10 @@ import 'dart:convert';
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 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -791,12 +791,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: displayedPosts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: displayedPosts,
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -1473,6 +1474,8 @@ class _FavoriteArtistsSection extends ConsumerWidget {
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
@@ -1574,12 +1577,10 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: posts,
),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
@@ -1600,6 +1601,8 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
@@ -1707,6 +1710,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback
: CachedNetworkImage(
imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover,
placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback,
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -167,12 +168,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: items,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: items,
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) {
return CachedNetworkImage(
imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length)
CachedNetworkImage(
imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest),
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -970,12 +971,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
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,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
onFavorite: (post) =>
ref.read(favoritesControllerProvider.notifier).remove(post),
@@ -1226,6 +1225,8 @@ class _ArtistCard extends StatelessWidget {
),
child: CachedNetworkImage(
imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
@@ -8,6 +8,7 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -143,7 +144,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyR): const _RandomPostIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR):
const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyV): const _ToggleSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
@@ -157,6 +162,24 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return null;
},
),
_RandomPostIntent: CallbackAction<_RandomPostIntent>(
onInvoke: (_) {
final posts = feed.value?.posts;
if (posts != null && posts.isNotEmpty) {
_openRandom(posts);
}
return null;
},
),
_ToggleSelectionIntent: CallbackAction<_ToggleSelectionIntent>(
onInvoke: (_) {
setState(() {
_selectionMode = !_selectionMode;
if (!_selectionMode) _selectedKeys.clear();
});
return null;
},
),
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
onInvoke: (_) {
_clearSelection();
@@ -331,12 +354,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
gridMode: settings.gridMode,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: state.posts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: state.posts,
),
onPreview: (post) => _showPreview(context, post),
onToggleSelected: (post) => _toggleSelected(post),
@@ -392,7 +413,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
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);
AppNavigator.openPost(context, post: post, postsList: posts);
}
Future<void> _applySearchQuery(String query) async {
@@ -683,6 +704,14 @@ class _SelectVisibleIntent extends Intent {
const _SelectVisibleIntent();
}
class _RandomPostIntent extends Intent {
const _RandomPostIntent();
}
class _ToggleSelectionIntent extends Intent {
const _ToggleSelectionIntent();
}
class _BatchActionBar extends StatelessWidget {
const _BatchActionBar({
required this.count,
@@ -110,7 +110,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
@@ -139,13 +139,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Обновить',
tooltip: 'Обновить (Ctrl+R / F5)',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: 'Случайный пост',
tooltip: 'Случайный пост (R)',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
@@ -163,41 +163,36 @@ class _FeedToolbarState extends State<FeedToolbar> {
const SizedBox(height: 8),
],
_HorizontalWheelScroller(
height: 36,
height: 38,
builder: (controller) => ListView(
controller: controller,
scrollDirection: Axis.horizontal,
primary: false,
children: [
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
const SizedBox(width: 8),
const _ToolbarDivider(),
const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все источники'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
_LiquidProviderPill(
label: 'Все источники',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 15),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
const SizedBox(width: 4),
const _ToolbarDivider(),
const SizedBox(width: 10),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 8),
],
],
),
@@ -289,64 +284,56 @@ class _FeedToolbarState extends State<FeedToolbar> {
primary: false,
children: [
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
label: const Text('Сбросить'),
onPressed: widget.onClearFilters,
_LiquidProviderPill(
label: 'Сбросить',
isSelected: false,
icon: Icons.filter_alt_off_rounded,
onTap: widget.onClearFilters,
),
const SizedBox(width: 6),
],
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Обновить'),
onPressed: widget.onRefresh,
_LiquidProviderPill(
label: 'Обновить',
isSelected: false,
icon: Icons.refresh_rounded,
onTap: widget.onRefresh,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.rating != null,
avatar: const Icon(Icons.shield_outlined, size: 15),
label: Text(widget.rating ?? 'Рейтинг'),
onSelected: (_) => widget.onRatingFilter(),
_LiquidProviderPill(
label: widget.rating ?? 'Рейтинг',
isSelected: widget.rating != null,
icon: Icons.shield_outlined,
onTap: widget.onRatingFilter,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
if (widget.providers.isNotEmpty) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidProviderPill(
label: 'Все',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected:
widget.selectedProviderIds.contains(provider.id),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 6),
],
const SizedBox(width: 2),
const _ToolbarDivider(),
const SizedBox(width: 6),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
visualDensity: VisualDensity.compact,
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 6),
],
],
),
@@ -371,6 +358,227 @@ class _ToolbarDivider extends StatelessWidget {
}
}
class _LiquidPeriodTabs extends StatelessWidget {
const _LiquidPeriodTabs({
required this.selectedPeriod,
required this.onChanged,
});
final TopPeriodFilter selectedPeriod;
final ValueChanged<TopPeriodFilter> onChanged;
IconData _iconFor(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => Icons.auto_awesome_rounded,
TopPeriodFilter.month => Icons.calendar_month_rounded,
TopPeriodFilter.year => Icons.calendar_today_rounded,
TopPeriodFilter.allTime => Icons.workspace_premium_rounded,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final period in TopPeriodFilter.values) ...[
_LiquidPeriodTabItem(
period: period,
icon: _iconFor(period),
isSelected: selectedPeriod == period,
onTap: () => onChanged(period),
),
if (period != TopPeriodFilter.values.last) const SizedBox(width: 3),
],
],
),
);
}
}
class _LiquidPeriodTabItem extends StatelessWidget {
const _LiquidPeriodTabItem({
required this.period,
required this.icon,
required this.isSelected,
required this.onTap,
});
final TopPeriodFilter period;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(13),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.primary.withValues(alpha: 0.28)
: scheme.primaryContainer.withValues(alpha: 0.85))
: Colors.transparent,
borderRadius: BorderRadius.circular(13),
border: isSelected
? Border.all(
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.3),
width: 1,
)
: null,
boxShadow: isSelected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.85),
),
const SizedBox(width: 6),
Text(
period.label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _LiquidProviderPill extends StatelessWidget {
const _LiquidProviderPill({
required this.label,
required this.isSelected,
required this.onTap,
this.icon,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.secondaryContainer.withValues(alpha: 0.35)
: scheme.secondaryContainer.withValues(alpha: 0.7))
: (isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.4)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35)),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? scheme.secondary.withValues(alpha: isDark ? 0.6 : 0.4)
: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05)),
width: 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: scheme.secondary.withValues(alpha: isDark ? 0.2 : 0.08),
blurRadius: 6,
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
),
const SizedBox(width: 5),
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _HorizontalWheelScroller extends StatefulWidget {
const _HorizontalWheelScroller({
required this.builder,
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/app_strings.dart';
import '../../../app/motion.dart';
import '../../../app/responsive.dart';
@@ -171,6 +172,8 @@ class PostDetailsScreen extends ConsumerWidget {
LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
},
child: Actions(
@@ -872,7 +875,7 @@ class PostDetailsScreen extends ConsumerWidget {
}
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
AppNavigator.openSimilarPosts(context, post: post);
}
Future<void> _openSourcePage(WidgetRef ref, Post post) async {
@@ -888,7 +891,16 @@ class PostDetailsScreen extends ConsumerWidget {
}
void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post);
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
postsList: postsList,
),
),
);
}
void _close(BuildContext context) {
@@ -1143,6 +1155,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)),
@@ -1,8 +1,8 @@
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/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -98,9 +98,9 @@ class SimilarPostsScreen extends ConsumerWidget {
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favorites,
viewedKeys: viewed,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
),
onFavorite: (post) =>
_toggleFavorite(ref, post, favorites),
@@ -317,10 +317,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
);
} else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
@@ -383,16 +389,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
],
);
final interactiveChild = _imageUrls.length > 1
? Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA): const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyD): const _NextImageIntent(),
},
child: Actions(
actions: {
_PrevImageIntent: CallbackAction<_PrevImageIntent>(
onInvoke: (_) {
if (_imageIndex > 0) {
setState(() => _imageIndex--);
}
return null;
},
),
_NextImageIntent: CallbackAction<_NextImageIntent>(
onInvoke: (_) {
if (_imageIndex < _imageUrls.length - 1) {
setState(() => _imageIndex++);
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: child,
),
),
)
: child;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: child,
child: interactiveChild,
);
}
return child;
return interactiveChild;
},
);
}
@@ -739,6 +783,48 @@ class _TogglePlayIntent extends Intent {
const _TogglePlayIntent();
}
class _SeekVideoIntent extends Intent {
const _SeekVideoIntent(this.seconds);
final int seconds;
}
class _VolumeVideoIntent extends Intent {
const _VolumeVideoIntent(this.deltaY);
final double deltaY;
}
class _MuteVideoIntent extends Intent {
const _MuteVideoIntent();
}
class _FullscreenVideoIntent extends Intent {
const _FullscreenVideoIntent();
}
class _LoopVideoIntent extends Intent {
const _LoopVideoIntent();
}
class _ZoomInIntent extends Intent {
const _ZoomInIntent();
}
class _ZoomOutIntent extends Intent {
const _ZoomOutIntent();
}
class _ResetZoomIntent extends Intent {
const _ResetZoomIntent();
}
class _PrevImageIntent extends Intent {
const _PrevImageIntent();
}
class _NextImageIntent extends Intent {
const _NextImageIntent();
}
class _UnsupportedSwfPanel extends StatelessWidget {
const _UnsupportedSwfPanel();
@@ -911,6 +997,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _CloseVideoIntent(),
},
child: Actions(
actions: {
@@ -1771,16 +1859,87 @@ class _VideoSurfaceState extends State<_VideoSurface> {
},
);
return MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.keyJ): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.keyL): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft):
const _SeekVideoIntent(-15),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight):
const _SeekVideoIntent(15),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const _VolumeVideoIntent(-10),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const _VolumeVideoIntent(10),
LogicalKeySet(LogicalKeyboardKey.keyM): const _MuteVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _FullscreenVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL):
const _LoopVideoIntent(),
},
child: Actions(
actions: {
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
onInvoke: (_) {
if (widget.player.state.playing) {
widget.player.pause();
} else {
if (widget.player.state.completed) {
widget.player.seek(Duration.zero);
}
widget.player.play();
}
widget.onInteract();
return null;
},
),
_SeekVideoIntent: CallbackAction<_SeekVideoIntent>(
onInvoke: (intent) {
_seekBy(Duration(seconds: intent.seconds));
return null;
},
),
_VolumeVideoIntent: CallbackAction<_VolumeVideoIntent>(
onInvoke: (intent) {
_adjustVolume(intent.deltaY);
return null;
},
),
_MuteVideoIntent: CallbackAction<_MuteVideoIntent>(
onInvoke: (_) {
widget.onToggleMute();
return null;
},
),
_FullscreenVideoIntent: CallbackAction<_FullscreenVideoIntent>(
onInvoke: (_) {
widget.onFullscreen();
return null;
},
),
_LoopVideoIntent: CallbackAction<_LoopVideoIntent>(
onInvoke: (_) {
widget.onToggleLoop();
return null;
},
),
},
child: Focus(
autofocus: true,
child: MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
),
),
),
),
);
}
@@ -1959,80 +2118,133 @@ class _ZoomableImageState extends State<_ZoomableImage> {
super.dispose();
}
void _zoomIn() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * 1.35).clamp(1.0, 6.0);
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = newScale > 1.02);
_setLocked(_zoomed);
}
void _zoomOut() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale / 1.35).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
void _resetZoom() {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85;
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
final focalPoint = signal.localPosition;
_controller.value = Matrix4.identity()
..translateByDouble(
-focalPoint.dx * (newScale - 1),
-focalPoint.dy * (newScale - 1),
0,
1)
..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
},
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: child,
),
),
);
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.equal): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.add): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadAdd): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.minus): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadSubtract): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.digit0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.numpad0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ResetZoomIntent(),
},
child: Actions(
actions: {
_ZoomInIntent: CallbackAction<_ZoomInIntent>(
onInvoke: (_) {
_zoomIn();
return null;
},
),
_ZoomOutIntent: CallbackAction<_ZoomOutIntent>(
onInvoke: (_) {
_zoomOut();
return null;
},
),
_ResetZoomIntent: CallbackAction<_ResetZoomIntent>(
onInvoke: (_) {
if (_zoomed) {
_resetZoom();
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
),
),
);
},
),
),
),
);
}
@@ -288,14 +288,6 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
value: 'realbooru_html',
child: Text('Realbooru HTML'),
),
DropdownMenuItem(
value: 'kemono',
child: Text('Kemono Party artists'),
),
DropdownMenuItem(
value: 'coomer',
child: Text('Coomer Party artists'),
),
DropdownMenuItem(
value: 'custom',
child: Text('Custom REST API'),
@@ -305,16 +305,6 @@ class ProviderCard extends StatelessWidget {
Icons.pets_rounded,
'e621 / e926',
),
'kemono' => (
const Color(0xFFEC4899),
Icons.palette_rounded,
'Kemono',
),
'coomer' => (
const Color(0xFF06B6D4),
Icons.collections_rounded,
'Coomer',
),
_ => (
const Color(0xFF6366F1),
Icons.hub_rounded,
@@ -1,9 +1,9 @@
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/app_navigator.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -62,9 +62,9 @@ class HiddenPostsScreen extends ConsumerWidget {
item: item,
onOpen: item.post == null
? null
: () => context.push(
'/post/${item.post!.providerId}/${item.post!.id}',
extra: item.post,
: () => AppNavigator.openPost(
context,
post: item.post!,
),
onRestore: () async {
await ref
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -142,12 +143,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: allPosts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: allPosts,
),
onFavorite: (post) => _toggleFavorite(
ref,
@@ -260,12 +259,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
isFavorite: favoriteKeys.contains(item.post.cacheKey),
isRu: isRu,
onOpen: () {
context.push(
'/post/${item.post.providerId}/${item.post.id}',
extra: PostNavigationContext(
currentPost: item.post,
posts: allPosts,
),
AppNavigator.openPost(
context,
post: item.post,
postsList: allPosts,
);
},
onFavorite: () => _toggleFavorite(
@@ -430,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
)
: CachedNetworkImage(
imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
+3
View File
@@ -5,5 +5,8 @@ import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Optimize image cache to prevent memory bloat and OOM crashes
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
runApp(const ProviderScope(child: GelRuleApp()));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class DesktopShortcutsDialog extends StatelessWidget {
const DesktopShortcutsDialog({super.key});
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => const DesktopShortcutsDialog(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 680),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.88)
: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.16)
: scheme.outlineVariant.withValues(alpha: 0.4),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 32,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.keyboard_rounded,
color: scheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Горячие клавиши',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Быстрое управление с клавиатуры для ПК',
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
tooltip: 'Закрыть (Esc)',
),
],
),
),
const Divider(height: 1),
// Shortcuts list
Flexible(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: const [
_ShortcutSection(
icon: Icons.navigation_rounded,
title: 'Общие и навигация',
items: [
_ShortcutRow(['Ctrl', '1…8'], 'Переключение между вкладками'),
_ShortcutRow(['Ctrl', 'Tab'], 'Следующая вкладка'),
_ShortcutRow(['Ctrl', 'Shift', 'Tab'], 'Предыдущая вкладка'),
_ShortcutRow(['Ctrl', 'F'], 'Перейти к поиску / строка поиска'),
_ShortcutRow(['Ctrl', 'R'], 'Обновить ленту / страницу'),
_ShortcutRow(['F5'], 'Обновить страницу'),
_ShortcutRow(['Esc'], 'Закрыть окно / назад / сбросить выбор'),
_ShortcutRow(['?'], 'Открыть это окно горячих клавиш'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.smart_display_rounded,
title: 'Видеоплеер',
items: [
_ShortcutRow(['Пробел'], 'Воспроизведение / Пауза'),
_ShortcutRow(['←', '→'], 'Перемотка на 5 секунд назад / вперед'),
_ShortcutRow(['Shift', '← / →'], 'Перемотка на 15 секунд'),
_ShortcutRow(['↑', '↓'], 'Регулировка громкости (±5%)'),
_ShortcutRow(['M'], 'Включить / выключить звук'),
_ShortcutRow(['F'], 'Полноэкранный режим (Full Screen)'),
_ShortcutRow(['L'], 'Повтор видео (Loop on/off)'),
_ShortcutRow(['Esc'], 'Выйти из полноэкранного режима'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.photo_library_rounded,
title: 'Фото и просмотр постов',
items: [
_ShortcutRow(['←', '→'], 'Предыдущее / следующее фото или пост'),
_ShortcutRow(['+'], 'Увеличить масштаб (Zoom In)'),
_ShortcutRow(['-'], 'Уменьшить масштаб (Zoom Out)'),
_ShortcutRow(['0'], 'Сбросить масштаб (100%)'),
_ShortcutRow(['F'], 'Подгонка по экрану / на весь экран'),
_ShortcutRow(['D'], 'Скачать текущее изображение'),
_ShortcutRow(['Ctrl', 'S'], 'Сохранить / Скачать файл'),
_ShortcutRow(['Esc'], 'Сброс зума / назад'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.grid_view_rounded,
title: 'Лента и посты',
items: [
_ShortcutRow(['R'], 'Открыть случайный пост'),
_ShortcutRow(['V'], 'Включить/выключить режим выбора постов'),
],
),
],
),
),
],
),
),
),
),
),
);
}
}
class _ShortcutSection extends StatelessWidget {
const _ShortcutSection({
required this.icon,
required this.title,
required this.items,
});
final IconData icon;
final String title;
final List<_ShortcutRow> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.3,
),
),
],
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: isDark
? scheme.surface.withValues(alpha: 0.6)
: scheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.04),
),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.04),
),
items[i],
],
],
),
),
],
);
}
}
class _ShortcutRow extends StatelessWidget {
const _ShortcutRow(this.keys, this.description);
final List<String> keys;
final String description;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 12),
Wrap(
spacing: 4,
runSpacing: 4,
children: keys.map((key) => _KeyCap(key)).toList(growable: false),
),
],
),
);
}
}
class _KeyCap extends StatelessWidget {
const _KeyCap(this.text);
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final scheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHighest.withValues(alpha: 0.9)
: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.15),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08),
offset: const Offset(0, 1.5),
blurRadius: 1,
),
],
),
child: Text(
text,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.bold,
color: scheme.onSurface,
letterSpacing: 0.2,
),
),
);
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ class _PostCardState extends ConsumerState<PostCard>
}
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
(constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) {
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
return RepaintBoundary(
child: PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
);
}
+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: 3.5.3+79
version: 3.6.2+84
environment:
sdk: ">=3.3.0 <4.0.0"
+23
View File
@@ -111,5 +111,28 @@ void main() {
expect(MediaUrlSelector.isAudio(photoPost), isFalse);
expect(MediaUrlSelector.isVideo(photoPost), isFalse);
});
test('MediaUrlSelector classifies mp4 containing "audio" in query parameter as video and not audio', () {
final trickyVideoPost = Post(
id: 'patreon:123:456:3',
providerId: 'pawchive',
providerName: 'Pawchive',
previewUrl: '',
sampleUrl: '',
fileUrl:
'https://file.pawchive.pw/data/b4/65/b465cb03ab7b26c0a677db3ae43500d1ef71e3e25151f160cda244c1fb40cfae.mp4?f=nsfw%202%20-%20audio%20spicy.mp4',
tags: const ['patreon', 'artist', 'nsfw 2 - audio spicy'],
rating: 'unknown',
width: 1920,
height: 1080,
createdAt: DateTime.now(),
fileType: 'video',
score: 0,
);
expect(MediaUrlSelector.isVideo(trickyVideoPost), isTrue);
expect(MediaUrlSelector.isAudio(trickyVideoPost), isFalse);
expect(MediaUrlSelector.video(trickyVideoPost), contains(trickyVideoPost.fileUrl));
});
}
+2 -3
View File
@@ -6,7 +6,6 @@ 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/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_html_provider.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
@@ -307,7 +306,7 @@ void main() {
expect(provider, isA<RealbooruHtmlProvider>());
});
test('provider factory creates Kemono and Coomer artist providers', () {
test('provider factory treats removed Kemono and Coomer as unsupported', () {
final now = DateTime(2026);
for (final apiType in ['kemono', 'coomer']) {
final provider = ProviderFactory().create(ContentProviderConfig(
@@ -323,7 +322,7 @@ void main() {
updatedAt: now,
));
expect(provider, isA<KemonoProvider>());
expect(provider, isA<UnsupportedCustomProvider>());
}
});
}
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/backend.dart';
import 'package:gel_rule_app/shared/widgets/app_shell.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Tab Navigation & State Preservation Tests', () {
test('AppShell branchIndexForLocation and locationForBranchIndex map correctly', () {
expect(AppShell.branchIndexForLocation('/'), 0);
expect(AppShell.branchIndexForLocation('/?q=test'), 0);
expect(AppShell.branchIndexForLocation('/search'), 1);
expect(AppShell.branchIndexForLocation('/favorites'), 2);
expect(AppShell.branchIndexForLocation('/viewed'), 3);
expect(AppShell.branchIndexForLocation('/collections'), 4);
expect(AppShell.branchIndexForLocation('/collections/123'), 4);
expect(AppShell.branchIndexForLocation('/artists'), 5);
expect(AppShell.branchIndexForLocation('/artists/pawchive/patreon/99'), 5);
expect(AppShell.branchIndexForLocation('/providers'), 6);
expect(AppShell.branchIndexForLocation('/settings'), 7);
expect(AppShell.branchIndexForLocation('/settings/hidden'), 7);
expect(AppShell.locationForBranchIndex(0), '/');
expect(AppShell.locationForBranchIndex(1), '/search');
expect(AppShell.locationForBranchIndex(2), '/favorites');
expect(AppShell.locationForBranchIndex(3), '/viewed');
expect(AppShell.locationForBranchIndex(4), '/collections');
expect(AppShell.locationForBranchIndex(5), '/artists');
expect(AppShell.locationForBranchIndex(6), '/providers');
expect(AppShell.locationForBranchIndex(7), '/settings');
});
test('AppSettings preserves lastActiveLocation in JSON serialization', () {
const defaults = AppSettings.defaults;
expect(defaults.lastActiveLocation, '/');
final updated = defaults.copyWith(lastActiveLocation: '/artists/pawchive/patreon/12345');
expect(updated.lastActiveLocation, '/artists/pawchive/patreon/12345');
final json = updated.toJson();
expect(json['lastActiveLocation'], '/artists/pawchive/patreon/12345');
final restored = AppSettings.fromJson(json);
expect(restored.lastActiveLocation, '/artists/pawchive/patreon/12345');
});
});
}