From 369dee02beb6bde5a7bb200429f2c004ddad7147 Mon Sep 17 00:00:00 2001 From: RarDog Date: Mon, 25 May 2026 19:41:57 +0300 Subject: [PATCH] Rebrand to Lunaris and add CosBooru providers --- README.md | 82 ++++++------------- android/app/src/main/AndroidManifest.xml | 2 +- installer/{RuleGel.iss => Lunaris.iss} | 6 +- lib/app/app.dart | 4 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 25 ++++-- lib/backend/di/backend_providers.dart | 2 +- .../repositories/provider_repository.dart | 66 ++++++++++++++- lib/backend/services/settings_service.dart | 35 +++++++- lib/core/http/dio_client.dart | 2 +- .../presentation/post_details_screen.dart | 39 ++++++++- .../widgets/post_media_viewer.dart | 23 +++++- .../presentation/settings_screen.dart | 8 +- linux/runner/my_application.cc | 4 +- pubspec.yaml | 2 +- test/backend/isar_integration_test.dart | 42 ++++++++++ test/backend/provider_parser_test.dart | 38 ++++++++- windows/runner/Runner.rc | 6 +- windows/runner/main.cpp | 2 +- 19 files changed, 300 insertions(+), 92 deletions(-) rename installer/{RuleGel.iss => Lunaris.iss} (94%) diff --git a/README.md b/README.md index 9f967b8..30b0172 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,32 @@ -# RuleGel +# Lunaris -RuleGel — единое Flutter-приложение для просмотра booru/gelbooru/rule34-подобных источников в Pinterest-стиле. +Lunaris — локальное Flutter-приложение для просмотра booru/gelbooru/rule34-подобных источников в Pinterest-стиле. -Приложение не использует отдельный backend-сервер. Вся логика работает локально внутри Flutter: провайдеры API, кэш metadata, избранное, коллекции, история, настройки, диагностика и фильтры. +Приложение не использует отдельный backend-сервер. Вся логика работает внутри Flutter: API-провайдеры, metadata-кэш, избранное, коллекции, история, настройки, диагностика и фильтры. ## Возможности -- Pinterest-like masonry feed с несколькими провайдерами. -- Поиск по тегам через удобные chip-теги: ввёл тег, нажал пробел или Enter, тег стал отдельным элементом. +- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers. +- Поиск по тегам через chip-input: ввёл тег, нажал пробел или Enter, тег стал отдельным элементом. - Подсказки тегов из provider API. - Избранное, коллекции и история просмотренных постов. -- Smart blacklist и whitelist с правилами по тегам, рейтингу, провайдеру, типу файла и score. -- Детальный просмотр фото, GIF и видео. -- Видео-плеер с fullscreen, repeat, mute и понижением громкости. -- Desktop-only просмотр `.swf` через Ruffle/WebView на Windows. +- Smart blacklist/whitelist с правилами по тегам, рейтингу, провайдеру, типу файла и score. +- Детальный просмотр фото, GIF, видео и SWF на Windows. +- Видео-плеер с fullscreen, repeat, mute, half-volume и сохранением выбранных настроек. - Управление провайдерами, health-check и provider diagnostics. - Экспорт и импорт настроек. +- Автопроверка обновлений через Gitea releases. - Android и Windows сборки. ## Скачать Готовые сборки лежат в Gitea Releases: -[RuleGel v0.3.0](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v0.3.0-tag-chips-swf) +[Lunaris v1.1.0](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v1.1.0) -- `RuleGelSetup.exe` — установщик для Windows. -- `RuleGel-v0.3.0.apk` — APK для Android. +- `Lunaris-v1.1.0.apk` — APK для Android. +- `LunarisSetup-v1.1.0.exe` — установщик для Windows. +- `LunarisPortable-v1.1.0.zip` — portable-версия для Windows. ## Стек @@ -39,11 +40,10 @@ RuleGel — единое Flutter-приложение для просмотра - media_kit - webview_windows для SWF/Ruffle на Windows - connectivity_plus -- freezed/json_serializable/build_runner ## Архитектура -RuleGel — Flutter-монолит. UI не дублирует backend-логику, а вызывает локальные сервисы через Riverpod. +Lunaris — Flutter-монолит. UI не дублирует backend-логику, а вызывает локальные сервисы через Riverpod. Основные слои: @@ -80,16 +80,21 @@ RuleGel — Flutter-монолит. UI не дублирует backend-логи - Yande.re — `https://yande.re` - e621 — `https://e621.net` - e926 — `https://e926.net` +- Xbooru — `https://xbooru.com` +- CosBooru — `https://cos.lycore.co` + +Realbooru оставлен в списке провайдеров, но выключен по умолчанию, потому что его публичный API сейчас нестабилен. Поддерживаемые `apiType`: - `gelbooru` - `rule34` +- `realbooru` - `danbooru` - `moebooru` - `e621` -Один упавший провайдер не ломает общий feed: ошибки сохраняются в diagnostics, а остальные провайдеры продолжают отдавать посты. +Один упавший провайдер не ломает общий feed: ошибки сохраняются в diagnostics, остальные провайдеры продолжают отдавать посты. ## Как добавить провайдера @@ -133,37 +138,6 @@ Isar хранит: Кэш хранит только metadata. Оригинальные медиафайлы не скачиваются автоматически. -## Использование сервисов из UI - -UI получает сервисы через Riverpod из `lib/backend/di/backend_providers.dart`. - -Пример feed: - -```dart -final result = await ref.read(feedServiceProvider).refresh( - tags: ['touhou', 'hakurei_reimu'], - rating: 'safe', -); -``` - -Пример избранного: - -```dart -await ref.read(favoriteServiceProvider).addFavorite(post); - -final saved = await ref - .read(favoriteServiceProvider) - .isFavorite(post.id, post.providerId); -``` - -Пример настроек: - -```dart -final settings = ref.read(settingsServiceProvider); -final exported = await settings.exportSettingsToJson(); -await settings.importSettingsFromJson(exported.data); -``` - ## Сборка и запуск Установить зависимости: @@ -172,12 +146,6 @@ await settings.importSettingsFromJson(exported.data); flutter pub get ``` -Сгенерировать Isar-код: - -```bash -dart run build_runner build --delete-conflicting-outputs -``` - Запустить: ```bash @@ -195,7 +163,7 @@ flutter build apk Собрать Windows installer: ```powershell -& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer\RuleGel.iss +& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer\Lunaris.iss ``` ## Важные заметки @@ -203,13 +171,13 @@ flutter build apk - На Windows для Flutter plugins может понадобиться Developer Mode. - Android-сборка требует Android SDK и принятые `flutter doctor --android-licenses`. - Linux build нужно собирать на Linux-хосте. -- Если путь проекта содержит апостроф, Flutter Windows/test tooling может капризничать. Для сборки можно использовать junction-путь без спецсимволов. -- SWF работает только на Windows. На Android он намеренно отключён. +- Если путь проекта содержит апостроф, Flutter Windows/test tooling может ломаться. Для сборки можно использовать копию проекта в пути без спецсимволов. +- SWF работает только на Windows через Ruffle/WebView. На Android он намеренно отключён. - Для SWF на Windows может понадобиться Microsoft WebView2 Runtime. ## Безопасность -- RuleGel использует публичные API и metadata. +- Lunaris использует публичные API и metadata. - Приложение не обходит ограничения сайтов. - Оригинальные файлы скачиваются только вручную по действию пользователя. - Dio использует нормальный User-Agent. @@ -223,4 +191,4 @@ flutter analyze flutter test ``` -В текущей версии покрыты backend-сервисы, provider parsing, smart blacklist, viewed history и tag chip input. +Покрыты backend-сервисы, provider parsing, smart blacklist, viewed history, settings migration и tag chip input. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c186fba..aef4844 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ MaterialApp.router( - title: 'RuleGel', + title: 'Lunaris', debugShowCheckedModeBanner: false, theme: buildTheme(Brightness.light), darkTheme: buildTheme(Brightness.dark), @@ -79,7 +79,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> { await showDialog( context: context, builder: (context) => AlertDialog( - title: Text('RuleGel ${info.version} is available'), + title: Text('Lunaris ${info.version} is available'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 45393c3..bf96fc9 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appDisplayVersion = '1.0.2'; -const appBuildNumber = 10; +const appDisplayVersion = '1.1.0'; +const appBuildNumber = 11; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 46b8f8f..4c7ab4e 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,5 +1,16 @@ -const ruleGelChangelog = [ - RuleGelChange( +const lunarisChangelog = [ + LunarisChange( + version: '1.1.0', + title: 'Lunaris rebrand and CosBooru', + bullets: [ + 'RuleGel is now Lunaris while keeping the same package identity for updates.', + 'CosBooru now uses the working Danbooru-compatible API at cos.lycore.co.', + 'Xbooru was added as a default Gelbooru-compatible provider.', + 'Rule34 Paheal was removed from providers.', + 'Video player UI preferences are saved across fullscreen, details and restarts.', + ], + ), + LunarisChange( version: '1.0.2', title: 'Realbooru safety fix', bullets: [ @@ -8,7 +19,7 @@ const ruleGelChangelog = [ 'Existing enabled Realbooru configs are disabled on startup so the main feed is not polluted by a known-offline API.', ], ), - RuleGelChange( + LunarisChange( version: '1.0.1', title: 'Realbooru and mobile polish', bullets: [ @@ -18,7 +29,7 @@ const ruleGelChangelog = [ 'Tag chip input no longer jumps while typing.', ], ), - RuleGelChange( + LunarisChange( version: '1.0.0', title: 'Stable 1.0', bullets: [ @@ -28,7 +39,7 @@ const ruleGelChangelog = [ 'Windows portable package is published alongside APK and installer.', ], ), - RuleGelChange( + LunarisChange( version: '0.4.3', title: 'Bugfixes', bullets: [ @@ -40,8 +51,8 @@ const ruleGelChangelog = [ ), ]; -class RuleGelChange { - const RuleGelChange({ +class LunarisChange { + const LunarisChange({ required this.version, required this.title, required this.bullets, diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 72b71a1..30c923c 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -168,7 +168,7 @@ final updateServiceProvider = Provider((ref) { connectTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 12), headers: const { - 'User-Agent': 'RuleGel/1.0 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', 'Accept': 'application/json', }, ), diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 2322985..aaad70b 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -88,7 +88,7 @@ class ProviderRepository { priority: 5, timeoutSeconds: 20, customHeaders: const { - 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', }, createdAt: now, updatedAt: now, @@ -102,7 +102,7 @@ class ProviderRepository { priority: 6, timeoutSeconds: 20, customHeaders: const { - 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', }, createdAt: now, updatedAt: now, @@ -119,6 +119,30 @@ class ProviderRepository { createdAt: now, updatedAt: now, ), + ContentProviderConfig( + id: 'xbooru', + name: 'Xbooru', + baseUrl: 'https://xbooru.com', + apiType: 'gelbooru', + enabled: true, + priority: 8, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + ), + ContentProviderConfig( + id: 'cosbooru', + name: 'CosBooru', + baseUrl: 'https://cos.lycore.co', + apiType: 'danbooru', + enabled: true, + priority: 9, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + ), ]; } @@ -128,10 +152,35 @@ class ProviderRepository { await isar.providerConfigEntitys.where().findAll(); for (final provider in existingProviders) { if (provider.providerId == 'kemono' || - provider.providerId == 'coomer') { + provider.providerId == 'coomer' || + provider.providerId == 'paheal') { await isar.providerConfigEntitys.delete(provider.isarId); } } + await isar.providerHealthEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); + await isar.providerDiagnosticsEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); + await isar.cachedPostEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); + await isar.favoriteEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); + await isar.collectionPostEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); + await isar.viewedPostEntitys + .filter() + .providerIdEqualTo('paheal') + .deleteAll(); final seeds = seedProviders(); for (final seed in seeds) { final exists = await isar.providerConfigEntitys @@ -145,6 +194,17 @@ class ProviderRepository { exists.enabled = false; exists.updatedAt = DateTime.now(); await isar.providerConfigEntitys.put(exists); + } else if (seed.id == 'cosbooru' && + (exists.baseUrl != seed.baseUrl || + exists.apiType != seed.apiType)) { + exists.name = seed.name; + exists.baseUrl = seed.baseUrl; + exists.apiType = seed.apiType; + exists.enabled = seed.enabled; + exists.priority = seed.priority; + exists.timeoutSeconds = seed.timeoutSeconds; + exists.updatedAt = DateTime.now(); + await isar.providerConfigEntitys.put(exists); } } }); diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index d395412..7ef0752 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -32,6 +32,10 @@ class AppSettings { required this.mediaQualityMode, required this.motionRefreshMode, required this.autoBatterySaver60Hz, + required this.videoPlayerMuted, + required this.videoPlayerHalfVolume, + required this.videoPlayerLoop, + required this.videoPlayerCover, required this.hiddenPostKeys, required this.diagnosticLogLines, required this.lastFeedTags, @@ -64,6 +68,10 @@ class AppSettings { final String mediaQualityMode; final String motionRefreshMode; final bool autoBatterySaver60Hz; + final bool videoPlayerMuted; + final bool videoPlayerHalfVolume; + final bool videoPlayerLoop; + final bool videoPlayerCover; final List hiddenPostKeys; final List diagnosticLogLines; final List lastFeedTags; @@ -96,6 +104,10 @@ class AppSettings { mediaQualityMode: 'auto', motionRefreshMode: 'auto', autoBatterySaver60Hz: true, + videoPlayerMuted: false, + videoPlayerHalfVolume: false, + videoPlayerLoop: false, + videoPlayerCover: false, hiddenPostKeys: [], diagnosticLogLines: [], lastFeedTags: [], @@ -129,6 +141,10 @@ class AppSettings { String? mediaQualityMode, String? motionRefreshMode, bool? autoBatterySaver60Hz, + bool? videoPlayerMuted, + bool? videoPlayerHalfVolume, + bool? videoPlayerLoop, + bool? videoPlayerCover, List? hiddenPostKeys, List? diagnosticLogLines, List? lastFeedTags, @@ -164,6 +180,11 @@ class AppSettings { mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode, motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode, autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz, + videoPlayerMuted: videoPlayerMuted ?? this.videoPlayerMuted, + videoPlayerHalfVolume: + videoPlayerHalfVolume ?? this.videoPlayerHalfVolume, + videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop, + videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover, hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys, diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines, lastFeedTags: lastFeedTags ?? this.lastFeedTags, @@ -199,6 +220,10 @@ class AppSettings { 'mediaQualityMode': mediaQualityMode, 'motionRefreshMode': motionRefreshMode, 'autoBatterySaver60Hz': autoBatterySaver60Hz, + 'videoPlayerMuted': videoPlayerMuted, + 'videoPlayerHalfVolume': videoPlayerHalfVolume, + 'videoPlayerLoop': videoPlayerLoop, + 'videoPlayerCover': videoPlayerCover, 'hiddenPostKeys': hiddenPostKeys, 'diagnosticLogLines': diagnosticLogLines, 'lastFeedTags': lastFeedTags, @@ -259,6 +284,14 @@ class AppSettings { defaults.motionRefreshMode, autoBatterySaver60Hz: (json['autoBatterySaver60Hz'] as bool?) ?? defaults.autoBatterySaver60Hz, + videoPlayerMuted: + (json['videoPlayerMuted'] as bool?) ?? defaults.videoPlayerMuted, + videoPlayerHalfVolume: (json['videoPlayerHalfVolume'] as bool?) ?? + defaults.videoPlayerHalfVolume, + videoPlayerLoop: + (json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop, + videoPlayerCover: + (json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover, hiddenPostKeys: List.from( (json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys, ), @@ -448,7 +481,7 @@ class SettingsService { return Success( const JsonEncoder.withIndent(' ').convert({ 'app': { - 'name': 'RuleGel', + 'name': 'Lunaris', 'version': appVersion, 'build': buildNumber, 'generatedAt': DateTime.now().toIso8601String(), diff --git a/lib/core/http/dio_client.dart b/lib/core/http/dio_client.dart index 5808b5a..62e4ec0 100644 --- a/lib/core/http/dio_client.dart +++ b/lib/core/http/dio_client.dart @@ -40,7 +40,7 @@ class DioClient { receiveTimeout: timeout, sendTimeout: timeout, headers: { - 'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', 'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8', ...?headers, }, diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 029e355..c3612c6 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -188,6 +188,13 @@ class PostDetailsScreen extends ConsumerWidget { key: ValueKey(post.cacheKey), post: post, qualityMode: qualityMode, + initialLoop: settings.videoPlayerLoop, + initialMuted: settings.videoPlayerMuted, + initialCoverVideo: settings.videoPlayerCover, + initialHalfVolume: + settings.videoPlayerHalfVolume, + onPlaybackPreferencesChanged: (snapshot) => + _saveVideoPreferences(ref, snapshot), ), ), ), @@ -290,6 +297,12 @@ class PostDetailsScreen extends ConsumerWidget { key: ValueKey(post.cacheKey), post: post, qualityMode: qualityMode, + initialLoop: settings.videoPlayerLoop, + initialMuted: settings.videoPlayerMuted, + initialCoverVideo: settings.videoPlayerCover, + initialHalfVolume: settings.videoPlayerHalfVolume, + onPlaybackPreferencesChanged: (snapshot) => + _saveVideoPreferences(ref, snapshot), ), ), ), @@ -479,6 +492,30 @@ class PostDetailsScreen extends ConsumerWidget { } context.go('/'); } + + Future _saveVideoPreferences( + WidgetRef ref, + VideoPlaybackSnapshot snapshot, + ) async { + final result = await ref.read(settingsServiceProvider).getSettings(); + if (result is! Success) return; + final settings = result.data; + if (settings.videoPlayerMuted == snapshot.muted && + settings.videoPlayerHalfVolume == snapshot.halfVolume && + settings.videoPlayerLoop == snapshot.loopVideo && + settings.videoPlayerCover == snapshot.coverVideo) { + return; + } + await ref.read(settingsServiceProvider).updateSettings( + settings.copyWith( + videoPlayerMuted: snapshot.muted, + videoPlayerHalfVolume: snapshot.halfVolume, + videoPlayerLoop: snapshot.loopVideo, + videoPlayerCover: snapshot.coverVideo, + ), + ); + ref.invalidate(appSettingsProvider); + } } class _MobilePostPager extends StatefulWidget { @@ -573,7 +610,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> { Map _headersFor(Post post) { return { - 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', 'Accept': '*/*', if (post.providerName.toLowerCase().contains('gelbooru') || post.fileUrl.contains('gelbooru.com') || diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 8300cc1..5801c19 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -27,6 +27,7 @@ class PostMediaViewer extends StatefulWidget { this.initialHalfVolume = false, this.qualityMode = MediaQualityMode.auto, this.onPlaybackSnapshot, + this.onPlaybackPreferencesChanged, super.key, }); @@ -40,6 +41,7 @@ class PostMediaViewer extends StatefulWidget { final bool initialHalfVolume; final MediaQualityMode qualityMode; final ValueChanged? onPlaybackSnapshot; + final ValueChanged? onPlaybackPreferencesChanged; @override State createState() => _PostMediaViewerState(); @@ -161,12 +163,14 @@ class _PostMediaViewerState extends State onRetry: _retryVideo, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); + _emitPlaybackPreferences(); _showControls(); }, onToggleMute: () async { final nextMuted = !_muted; setState(() => _muted = nextMuted); await _applyVolume(); + _emitPlaybackPreferences(); _showControls(); }, onToggleHalfVolume: () async { @@ -175,6 +179,7 @@ class _PostMediaViewerState extends State if (_halfVolume) _muted = false; }); await _applyVolume(); + _emitPlaybackPreferences(); _showControls(); }, onToggleLoop: () async { @@ -183,6 +188,7 @@ class _PostMediaViewerState extends State await _player!.setPlaylistMode( nextLoop ? PlaylistMode.single : PlaylistMode.none, ); + _emitPlaybackPreferences(); _showControls(); }, onFullscreen: widget.fullscreen @@ -342,7 +348,7 @@ class _PostMediaViewerState extends State Map _headersFor(Post post) { return { - 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', 'Accept': '*/*', if (post.providerName.toLowerCase().contains('gelbooru') || post.fileUrl.contains('gelbooru.com') || @@ -394,6 +400,7 @@ class _PostMediaViewerState extends State coverVideo: _coverVideo, errorMessage: _videoError, onRetry: _retryVideo, + onChanged: (_) => _emitPlaybackPreferences(), ), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, @@ -414,6 +421,7 @@ class _PostMediaViewerState extends State await player.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); + _emitPlaybackPreferences(); return; } } @@ -438,6 +446,13 @@ class _PostMediaViewerState extends State : 100); } + void _emitPlaybackPreferences() { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + widget.onPlaybackPreferencesChanged?.call(snapshot); + } + Future _togglePlay() async { final player = _player; if (player == null) return; @@ -750,6 +765,7 @@ class _FullscreenVideoPage extends StatefulWidget { required this.coverVideo, required this.errorMessage, required this.onRetry, + required this.onChanged, }); final Player player; @@ -761,6 +777,7 @@ class _FullscreenVideoPage extends StatefulWidget { final bool coverVideo; final String? errorMessage; final VoidCallback onRetry; + final ValueChanged onChanged; @override State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); @@ -849,11 +866,13 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { onRetry: widget.onRetry, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); + widget.onChanged(_snapshot()); _showControls(); }, onToggleMute: () async { setState(() => _muted = !_muted); await _applyVolume(); + widget.onChanged(_snapshot()); _showControls(); }, onToggleHalfVolume: () async { @@ -862,6 +881,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { if (_halfVolume) _muted = false; }); await _applyVolume(); + widget.onChanged(_snapshot()); _showControls(); }, onToggleLoop: () async { @@ -869,6 +889,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { await widget.player.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); + widget.onChanged(_snapshot()); _showControls(); }, onFullscreen: () => Navigator.of( diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index f6c2c83..8d5a552 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -392,7 +392,7 @@ class _SettingsContent extends ConsumerWidget { final update = (result as Success).data; if (update == null) { messenger.showSnackBar( - const SnackBar(content: Text('RuleGel is up to date')), + const SnackBar(content: Text('Lunaris is up to date')), ); return; } @@ -407,7 +407,7 @@ class _SettingsContent extends ConsumerWidget { await showDialog( context: context, builder: (context) => AlertDialog( - title: Text('RuleGel ${info.version} is available'), + title: Text('Lunaris ${info.version} is available'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( @@ -454,7 +454,7 @@ class _SettingsContent extends ConsumerWidget { return showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('RuleGel changelog'), + title: const Text('Lunaris changelog'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: SingleChildScrollView( @@ -462,7 +462,7 @@ class _SettingsContent extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - for (final change in ruleGelChangelog) ...[ + for (final change in lunarisChangelog) ...[ Text( '${change.version} - ${change.title}', style: Theme.of(context).textTheme.titleMedium, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 22b7e8c..172a86b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "gel_rule_app"); + gtk_header_bar_set_title(header_bar, "Lunaris"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "gel_rule_app"); + gtk_window_set_title(window, "Lunaris"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/pubspec.yaml b/pubspec.yaml index 497cc77..a50c58f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 1.0.2+10 +version: 1.1.0+11 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index b929711..e4b6f5c 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/content_provider_config.dart'; import 'package:gel_rule_app/backend/models/post.dart'; import 'package:gel_rule_app/backend/repositories/collection_repository.dart'; import 'package:gel_rule_app/backend/repositories/favorite_repository.dart'; @@ -73,11 +74,52 @@ void main() { 'e621', 'e926', 'realbooru', + 'xbooru', + 'cosbooru', ]); expect( providers.singleWhere((provider) => provider.id == 'realbooru').enabled, isFalse, ); + expect( + providers.singleWhere((provider) => provider.id == 'cosbooru').enabled, + isTrue, + ); + final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru'); + expect(xbooru.enabled, isTrue); + expect(xbooru.baseUrl, 'https://xbooru.com'); + expect(xbooru.apiType, 'gelbooru'); + final cosbooru = + providers.singleWhere((provider) => provider.id == 'cosbooru'); + expect(cosbooru.baseUrl, 'https://cos.lycore.co'); + expect(cosbooru.apiType, 'danbooru'); + }); + + test('provider repository migrates old CosBooru config', () async { + final repository = ProviderRepository(databaseService); + final now = DateTime(2026); + await repository.saveProvider(ContentProviderConfig( + id: 'cosbooru', + name: 'CosBooru', + baseUrl: 'https://cos.booru.nl', + apiType: 'gelbooru', + enabled: false, + priority: 99, + timeoutSeconds: 10, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + )); + + await repository.ensureSeedProviders(); + final providers = (await repository.getProviders() as Success).data; + final cosbooru = + providers.singleWhere((provider) => provider.id == 'cosbooru'); + + expect(cosbooru.baseUrl, 'https://cos.lycore.co'); + expect(cosbooru.apiType, 'danbooru'); + expect(cosbooru.enabled, isTrue); + expect(cosbooru.priority, 9); }); test('settings saveEnabledProviders updates provider configs', () async { diff --git a/test/backend/provider_parser_test.dart b/test/backend/provider_parser_test.dart index 5dd3e89..146fb84 100644 --- a/test/backend/provider_parser_test.dart +++ b/test/backend/provider_parser_test.dart @@ -5,6 +5,7 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart'; import 'package:gel_rule_app/backend/models/content_provider_config.dart'; +import 'package:gel_rule_app/backend/providers/danbooru_provider.dart'; import 'package:gel_rule_app/backend/providers/provider_factory.dart'; import 'package:gel_rule_app/backend/providers/realbooru_provider.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; @@ -205,7 +206,7 @@ void main() { expect(posts.single.rating, 'unknown'); }); - test('seed providers include enabled Realbooru', () { + test('seed providers include CosBooru and no Paheal', () { final realbooru = ProviderRepository.seedProviders() .where((provider) => provider.id == 'realbooru') .single; @@ -213,6 +214,23 @@ void main() { expect(realbooru.enabled, isFalse); expect(realbooru.apiType, 'realbooru'); expect(realbooru.baseUrl, 'https://realbooru.com'); + expect( + ProviderRepository.seedProviders() + .where((provider) => provider.id == 'paheal'), + isEmpty, + ); + final cosbooru = ProviderRepository.seedProviders() + .where((provider) => provider.id == 'cosbooru') + .single; + final xbooru = ProviderRepository.seedProviders() + .where((provider) => provider.id == 'xbooru') + .single; + expect(xbooru.enabled, isTrue); + expect(xbooru.apiType, 'gelbooru'); + expect(xbooru.baseUrl, 'https://xbooru.com'); + expect(cosbooru.enabled, isTrue); + expect(cosbooru.apiType, 'danbooru'); + expect(cosbooru.baseUrl, 'https://cos.lycore.co'); }); test('provider factory creates Realbooru provider', () { @@ -232,4 +250,22 @@ void main() { expect(provider, isA()); }); + + test('provider factory creates CosBooru as Danbooru provider', () { + final now = DateTime(2026); + final provider = ProviderFactory().create(ContentProviderConfig( + id: 'cosbooru', + name: 'CosBooru', + baseUrl: 'https://cos.lycore.co', + apiType: 'danbooru', + enabled: true, + priority: 8, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + )); + + expect(provider, isA()); + }); } diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index a843b3b..d1062c9 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "RuleGel" "\0" + VALUE "FileDescription", "Lunaris" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "RuleGel" "\0" + VALUE "InternalName", "Lunaris" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "gel_rule_app.exe" "\0" - VALUE "ProductName", "RuleGel" "\0" + VALUE "ProductName", "Lunaris" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 4f813cd..1d7c3dc 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"gel_rule_app", origin, size)) { + if (!window.Create(L"Lunaris", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);