Rebrand to Lunaris and add CosBooru providers

This commit is contained in:
2026-05-25 19:41:57 +03:00
parent 3bf7fe6305
commit 369dee02be
19 changed files with 300 additions and 92 deletions
+25 -57
View File
@@ -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 с несколькими провайдерами. - Masonry feed с несколькими провайдерами и перемешанной выдачей All providers.
- Поиск по тегам через удобные chip-теги: ввёл тег, нажал пробел или Enter, тег стал отдельным элементом. - Поиск по тегам через chip-input: ввёл тег, нажал пробел или Enter, тег стал отдельным элементом.
- Подсказки тегов из provider API. - Подсказки тегов из provider API.
- Избранное, коллекции и история просмотренных постов. - Избранное, коллекции и история просмотренных постов.
- Smart blacklist и whitelist с правилами по тегам, рейтингу, провайдеру, типу файла и score. - Smart blacklist/whitelist с правилами по тегам, рейтингу, провайдеру, типу файла и score.
- Детальный просмотр фото, GIF и видео. - Детальный просмотр фото, GIF, видео и SWF на Windows.
- Видео-плеер с fullscreen, repeat, mute и понижением громкости. - Видео-плеер с fullscreen, repeat, mute, half-volume и сохранением выбранных настроек.
- Desktop-only просмотр `.swf` через Ruffle/WebView на Windows.
- Управление провайдерами, health-check и provider diagnostics. - Управление провайдерами, health-check и provider diagnostics.
- Экспорт и импорт настроек. - Экспорт и импорт настроек.
- Автопроверка обновлений через Gitea releases.
- Android и Windows сборки. - Android и Windows сборки.
## Скачать ## Скачать
Готовые сборки лежат в Gitea Releases: Готовые сборки лежат в 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. - `Lunaris-v1.1.0.apk` — APK для Android.
- `RuleGel-v0.3.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 - media_kit
- webview_windows для SWF/Ruffle на Windows - webview_windows для SWF/Ruffle на Windows
- connectivity_plus - 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` - Yande.re — `https://yande.re`
- e621 — `https://e621.net` - e621 — `https://e621.net`
- e926 — `https://e926.net` - e926 — `https://e926.net`
- Xbooru — `https://xbooru.com`
- CosBooru — `https://cos.lycore.co`
Realbooru оставлен в списке провайдеров, но выключен по умолчанию, потому что его публичный API сейчас нестабилен.
Поддерживаемые `apiType`: Поддерживаемые `apiType`:
- `gelbooru` - `gelbooru`
- `rule34` - `rule34`
- `realbooru`
- `danbooru` - `danbooru`
- `moebooru` - `moebooru`
- `e621` - `e621`
Один упавший провайдер не ломает общий feed: ошибки сохраняются в diagnostics, а остальные провайдеры продолжают отдавать посты. Один упавший провайдер не ломает общий feed: ошибки сохраняются в diagnostics, остальные провайдеры продолжают отдавать посты.
## Как добавить провайдера ## Как добавить провайдера
@@ -133,37 +138,6 @@ Isar хранит:
Кэш хранит только metadata. Оригинальные медиафайлы не скачиваются автоматически. Кэш хранит только 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 flutter pub get
``` ```
Сгенерировать Isar-код:
```bash
dart run build_runner build --delete-conflicting-outputs
```
Запустить: Запустить:
```bash ```bash
@@ -195,7 +163,7 @@ flutter build apk
Собрать Windows installer: Собрать Windows installer:
```powershell ```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. - На Windows для Flutter plugins может понадобиться Developer Mode.
- Android-сборка требует Android SDK и принятые `flutter doctor --android-licenses`. - Android-сборка требует Android SDK и принятые `flutter doctor --android-licenses`.
- Linux build нужно собирать на Linux-хосте. - Linux build нужно собирать на Linux-хосте.
- Если путь проекта содержит апостроф, Flutter Windows/test tooling может капризничать. Для сборки можно использовать junction-путь без спецсимволов. - Если путь проекта содержит апостроф, Flutter Windows/test tooling может ломаться. Для сборки можно использовать копию проекта в пути без спецсимволов.
- SWF работает только на Windows. На Android он намеренно отключён. - SWF работает только на Windows через Ruffle/WebView. На Android он намеренно отключён.
- Для SWF на Windows может понадобиться Microsoft WebView2 Runtime. - Для SWF на Windows может понадобиться Microsoft WebView2 Runtime.
## Безопасность ## Безопасность
- RuleGel использует публичные API и metadata. - Lunaris использует публичные API и metadata.
- Приложение не обходит ограничения сайтов. - Приложение не обходит ограничения сайтов.
- Оригинальные файлы скачиваются только вручную по действию пользователя. - Оригинальные файлы скачиваются только вручную по действию пользователя.
- Dio использует нормальный User-Agent. - Dio использует нормальный User-Agent.
@@ -223,4 +191,4 @@ flutter analyze
flutter test flutter test
``` ```
В текущей версии покрыты backend-сервисы, provider parsing, smart blacklist, viewed history и tag chip input. Покрыты backend-сервисы, provider parsing, smart blacklist, viewed history, settings migration и tag chip input.
+1 -1
View File
@@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="RuleGel" android:label="Lunaris"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -1,5 +1,5 @@
#define MyAppName "RuleGel" #define MyAppName "Lunaris"
#define MyAppVersion "1.0.2" #define MyAppVersion "1.1.0"
#define MyAppPublisher "RarDog" #define MyAppPublisher "RarDog"
#define MyAppExeName "gel_rule_app.exe" #define MyAppExeName "gel_rule_app.exe"
@@ -13,7 +13,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
PrivilegesRequired=lowest PrivilegesRequired=lowest
OutputDir=..\dist OutputDir=..\dist
OutputBaseFilename=RuleGelSetup OutputBaseFilename=LunarisSetup
SetupIconFile=..\windows\runner\resources\app_icon.ico SetupIconFile=..\windows\runner\resources\app_icon.ico
Compression=lzma2 Compression=lzma2
SolidCompression=yes SolidCompression=yes
+2 -2
View File
@@ -32,7 +32,7 @@ class GelRuleApp extends ConsumerWidget {
home: _StartupErrorScreen(error: error), home: _StartupErrorScreen(error: error),
), ),
data: (_) => MaterialApp.router( data: (_) => MaterialApp.router(
title: 'RuleGel', title: 'Lunaris',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(Brightness.light), theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark), darkTheme: buildTheme(Brightness.dark),
@@ -79,7 +79,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text('RuleGel ${info.version} is available'), title: Text('Lunaris ${info.version} is available'),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: SingleChildScrollView( child: SingleChildScrollView(
+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '1.0.2'; const appDisplayVersion = '1.1.0';
const appBuildNumber = 10; const appBuildNumber = 11;
+18 -7
View File
@@ -1,5 +1,16 @@
const ruleGelChangelog = [ const lunarisChangelog = [
RuleGelChange( 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', version: '1.0.2',
title: 'Realbooru safety fix', title: 'Realbooru safety fix',
bullets: [ 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.', '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', version: '1.0.1',
title: 'Realbooru and mobile polish', title: 'Realbooru and mobile polish',
bullets: [ bullets: [
@@ -18,7 +29,7 @@ const ruleGelChangelog = [
'Tag chip input no longer jumps while typing.', 'Tag chip input no longer jumps while typing.',
], ],
), ),
RuleGelChange( LunarisChange(
version: '1.0.0', version: '1.0.0',
title: 'Stable 1.0', title: 'Stable 1.0',
bullets: [ bullets: [
@@ -28,7 +39,7 @@ const ruleGelChangelog = [
'Windows portable package is published alongside APK and installer.', 'Windows portable package is published alongside APK and installer.',
], ],
), ),
RuleGelChange( LunarisChange(
version: '0.4.3', version: '0.4.3',
title: 'Bugfixes', title: 'Bugfixes',
bullets: [ bullets: [
@@ -40,8 +51,8 @@ const ruleGelChangelog = [
), ),
]; ];
class RuleGelChange { class LunarisChange {
const RuleGelChange({ const LunarisChange({
required this.version, required this.version,
required this.title, required this.title,
required this.bullets, required this.bullets,
+1 -1
View File
@@ -168,7 +168,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
connectTimeout: const Duration(seconds: 12), connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 12),
headers: const { headers: const {
'User-Agent': 'RuleGel/1.0 Flutter local booru browser', 'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
), ),
@@ -88,7 +88,7 @@ class ProviderRepository {
priority: 5, priority: 5,
timeoutSeconds: 20, timeoutSeconds: 20,
customHeaders: const { customHeaders: const {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser', 'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
}, },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -102,7 +102,7 @@ class ProviderRepository {
priority: 6, priority: 6,
timeoutSeconds: 20, timeoutSeconds: 20,
customHeaders: const { customHeaders: const {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser', 'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
}, },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -119,6 +119,30 @@ class ProviderRepository {
createdAt: now, createdAt: now,
updatedAt: 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(); await isar.providerConfigEntitys.where().findAll();
for (final provider in existingProviders) { for (final provider in existingProviders) {
if (provider.providerId == 'kemono' || if (provider.providerId == 'kemono' ||
provider.providerId == 'coomer') { provider.providerId == 'coomer' ||
provider.providerId == 'paheal') {
await isar.providerConfigEntitys.delete(provider.isarId); 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(); final seeds = seedProviders();
for (final seed in seeds) { for (final seed in seeds) {
final exists = await isar.providerConfigEntitys final exists = await isar.providerConfigEntitys
@@ -145,6 +194,17 @@ class ProviderRepository {
exists.enabled = false; exists.enabled = false;
exists.updatedAt = DateTime.now(); exists.updatedAt = DateTime.now();
await isar.providerConfigEntitys.put(exists); 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);
} }
} }
}); });
+34 -1
View File
@@ -32,6 +32,10 @@ class AppSettings {
required this.mediaQualityMode, required this.mediaQualityMode,
required this.motionRefreshMode, required this.motionRefreshMode,
required this.autoBatterySaver60Hz, required this.autoBatterySaver60Hz,
required this.videoPlayerMuted,
required this.videoPlayerHalfVolume,
required this.videoPlayerLoop,
required this.videoPlayerCover,
required this.hiddenPostKeys, required this.hiddenPostKeys,
required this.diagnosticLogLines, required this.diagnosticLogLines,
required this.lastFeedTags, required this.lastFeedTags,
@@ -64,6 +68,10 @@ class AppSettings {
final String mediaQualityMode; final String mediaQualityMode;
final String motionRefreshMode; final String motionRefreshMode;
final bool autoBatterySaver60Hz; final bool autoBatterySaver60Hz;
final bool videoPlayerMuted;
final bool videoPlayerHalfVolume;
final bool videoPlayerLoop;
final bool videoPlayerCover;
final List<String> hiddenPostKeys; final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines; final List<String> diagnosticLogLines;
final List<String> lastFeedTags; final List<String> lastFeedTags;
@@ -96,6 +104,10 @@ class AppSettings {
mediaQualityMode: 'auto', mediaQualityMode: 'auto',
motionRefreshMode: 'auto', motionRefreshMode: 'auto',
autoBatterySaver60Hz: true, autoBatterySaver60Hz: true,
videoPlayerMuted: false,
videoPlayerHalfVolume: false,
videoPlayerLoop: false,
videoPlayerCover: false,
hiddenPostKeys: [], hiddenPostKeys: [],
diagnosticLogLines: [], diagnosticLogLines: [],
lastFeedTags: [], lastFeedTags: [],
@@ -129,6 +141,10 @@ class AppSettings {
String? mediaQualityMode, String? mediaQualityMode,
String? motionRefreshMode, String? motionRefreshMode,
bool? autoBatterySaver60Hz, bool? autoBatterySaver60Hz,
bool? videoPlayerMuted,
bool? videoPlayerHalfVolume,
bool? videoPlayerLoop,
bool? videoPlayerCover,
List<String>? hiddenPostKeys, List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines, List<String>? diagnosticLogLines,
List<String>? lastFeedTags, List<String>? lastFeedTags,
@@ -164,6 +180,11 @@ class AppSettings {
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode, mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode, motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode,
autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz, autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz,
videoPlayerMuted: videoPlayerMuted ?? this.videoPlayerMuted,
videoPlayerHalfVolume:
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys, hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines, diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags, lastFeedTags: lastFeedTags ?? this.lastFeedTags,
@@ -199,6 +220,10 @@ class AppSettings {
'mediaQualityMode': mediaQualityMode, 'mediaQualityMode': mediaQualityMode,
'motionRefreshMode': motionRefreshMode, 'motionRefreshMode': motionRefreshMode,
'autoBatterySaver60Hz': autoBatterySaver60Hz, 'autoBatterySaver60Hz': autoBatterySaver60Hz,
'videoPlayerMuted': videoPlayerMuted,
'videoPlayerHalfVolume': videoPlayerHalfVolume,
'videoPlayerLoop': videoPlayerLoop,
'videoPlayerCover': videoPlayerCover,
'hiddenPostKeys': hiddenPostKeys, 'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines, 'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags, 'lastFeedTags': lastFeedTags,
@@ -259,6 +284,14 @@ class AppSettings {
defaults.motionRefreshMode, defaults.motionRefreshMode,
autoBatterySaver60Hz: (json['autoBatterySaver60Hz'] as bool?) ?? autoBatterySaver60Hz: (json['autoBatterySaver60Hz'] as bool?) ??
defaults.autoBatterySaver60Hz, 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<String>.from( hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys, (json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
), ),
@@ -448,7 +481,7 @@ class SettingsService {
return Success( return Success(
const JsonEncoder.withIndent(' ').convert({ const JsonEncoder.withIndent(' ').convert({
'app': { 'app': {
'name': 'RuleGel', 'name': 'Lunaris',
'version': appVersion, 'version': appVersion,
'build': buildNumber, 'build': buildNumber,
'generatedAt': DateTime.now().toIso8601String(), 'generatedAt': DateTime.now().toIso8601String(),
+1 -1
View File
@@ -40,7 +40,7 @@ class DioClient {
receiveTimeout: timeout, receiveTimeout: timeout,
sendTimeout: timeout, sendTimeout: timeout,
headers: { 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', 'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
...?headers, ...?headers,
}, },
@@ -188,6 +188,13 @@ class PostDetailsScreen extends ConsumerWidget {
key: ValueKey(post.cacheKey), key: ValueKey(post.cacheKey),
post: post, post: post,
qualityMode: qualityMode, 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), key: ValueKey(post.cacheKey),
post: post, post: post,
qualityMode: qualityMode, 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('/'); context.go('/');
} }
Future<void> _saveVideoPreferences(
WidgetRef ref,
VideoPlaybackSnapshot snapshot,
) async {
final result = await ref.read(settingsServiceProvider).getSettings();
if (result is! Success<AppSettings>) 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 { class _MobilePostPager extends StatefulWidget {
@@ -573,7 +610,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
Map<String, String> _headersFor(Post post) { Map<String, String> _headersFor(Post post) {
return { return {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser', 'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
'Accept': '*/*', 'Accept': '*/*',
if (post.providerName.toLowerCase().contains('gelbooru') || if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') || post.fileUrl.contains('gelbooru.com') ||
@@ -27,6 +27,7 @@ class PostMediaViewer extends StatefulWidget {
this.initialHalfVolume = false, this.initialHalfVolume = false,
this.qualityMode = MediaQualityMode.auto, this.qualityMode = MediaQualityMode.auto,
this.onPlaybackSnapshot, this.onPlaybackSnapshot,
this.onPlaybackPreferencesChanged,
super.key, super.key,
}); });
@@ -40,6 +41,7 @@ class PostMediaViewer extends StatefulWidget {
final bool initialHalfVolume; final bool initialHalfVolume;
final MediaQualityMode qualityMode; final MediaQualityMode qualityMode;
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot; final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackPreferencesChanged;
@override @override
State<PostMediaViewer> createState() => _PostMediaViewerState(); State<PostMediaViewer> createState() => _PostMediaViewerState();
@@ -161,12 +163,14 @@ class _PostMediaViewerState extends State<PostMediaViewer>
onRetry: _retryVideo, onRetry: _retryVideo,
onToggleFit: () { onToggleFit: () {
setState(() => _coverVideo = !_coverVideo); setState(() => _coverVideo = !_coverVideo);
_emitPlaybackPreferences();
_showControls(); _showControls();
}, },
onToggleMute: () async { onToggleMute: () async {
final nextMuted = !_muted; final nextMuted = !_muted;
setState(() => _muted = nextMuted); setState(() => _muted = nextMuted);
await _applyVolume(); await _applyVolume();
_emitPlaybackPreferences();
_showControls(); _showControls();
}, },
onToggleHalfVolume: () async { onToggleHalfVolume: () async {
@@ -175,6 +179,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
if (_halfVolume) _muted = false; if (_halfVolume) _muted = false;
}); });
await _applyVolume(); await _applyVolume();
_emitPlaybackPreferences();
_showControls(); _showControls();
}, },
onToggleLoop: () async { onToggleLoop: () async {
@@ -183,6 +188,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
await _player!.setPlaylistMode( await _player!.setPlaylistMode(
nextLoop ? PlaylistMode.single : PlaylistMode.none, nextLoop ? PlaylistMode.single : PlaylistMode.none,
); );
_emitPlaybackPreferences();
_showControls(); _showControls();
}, },
onFullscreen: widget.fullscreen onFullscreen: widget.fullscreen
@@ -342,7 +348,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
Map<String, String> _headersFor(Post post) { Map<String, String> _headersFor(Post post) {
return { return {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser', 'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
'Accept': '*/*', 'Accept': '*/*',
if (post.providerName.toLowerCase().contains('gelbooru') || if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') || post.fileUrl.contains('gelbooru.com') ||
@@ -394,6 +400,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
coverVideo: _coverVideo, coverVideo: _coverVideo,
errorMessage: _videoError, errorMessage: _videoError,
onRetry: _retryVideo, onRetry: _retryVideo,
onChanged: (_) => _emitPlaybackPreferences(),
), ),
transitionsBuilder: (_, animation, __, child) => FadeTransition( transitionsBuilder: (_, animation, __, child) => FadeTransition(
opacity: animation, opacity: animation,
@@ -414,6 +421,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
await player.setPlaylistMode( await player.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none, _loopVideo ? PlaylistMode.single : PlaylistMode.none,
); );
_emitPlaybackPreferences();
return; return;
} }
} }
@@ -438,6 +446,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
: 100); : 100);
} }
void _emitPlaybackPreferences() {
final snapshot = _snapshot();
_playbackMemory[widget.post.cacheKey] = snapshot;
widget.onPlaybackSnapshot?.call(snapshot);
widget.onPlaybackPreferencesChanged?.call(snapshot);
}
Future<void> _togglePlay() async { Future<void> _togglePlay() async {
final player = _player; final player = _player;
if (player == null) return; if (player == null) return;
@@ -750,6 +765,7 @@ class _FullscreenVideoPage extends StatefulWidget {
required this.coverVideo, required this.coverVideo,
required this.errorMessage, required this.errorMessage,
required this.onRetry, required this.onRetry,
required this.onChanged,
}); });
final Player player; final Player player;
@@ -761,6 +777,7 @@ class _FullscreenVideoPage extends StatefulWidget {
final bool coverVideo; final bool coverVideo;
final String? errorMessage; final String? errorMessage;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged;
@override @override
State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
@@ -849,11 +866,13 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
onRetry: widget.onRetry, onRetry: widget.onRetry,
onToggleFit: () { onToggleFit: () {
setState(() => _coverVideo = !_coverVideo); setState(() => _coverVideo = !_coverVideo);
widget.onChanged(_snapshot());
_showControls(); _showControls();
}, },
onToggleMute: () async { onToggleMute: () async {
setState(() => _muted = !_muted); setState(() => _muted = !_muted);
await _applyVolume(); await _applyVolume();
widget.onChanged(_snapshot());
_showControls(); _showControls();
}, },
onToggleHalfVolume: () async { onToggleHalfVolume: () async {
@@ -862,6 +881,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
if (_halfVolume) _muted = false; if (_halfVolume) _muted = false;
}); });
await _applyVolume(); await _applyVolume();
widget.onChanged(_snapshot());
_showControls(); _showControls();
}, },
onToggleLoop: () async { onToggleLoop: () async {
@@ -869,6 +889,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
await widget.player.setPlaylistMode( await widget.player.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none, _loopVideo ? PlaylistMode.single : PlaylistMode.none,
); );
widget.onChanged(_snapshot());
_showControls(); _showControls();
}, },
onFullscreen: () => Navigator.of( onFullscreen: () => Navigator.of(
@@ -392,7 +392,7 @@ class _SettingsContent extends ConsumerWidget {
final update = (result as Success<AppUpdateInfo?>).data; final update = (result as Success<AppUpdateInfo?>).data;
if (update == null) { if (update == null) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('RuleGel is up to date')), const SnackBar(content: Text('Lunaris is up to date')),
); );
return; return;
} }
@@ -407,7 +407,7 @@ class _SettingsContent extends ConsumerWidget {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text('RuleGel ${info.version} is available'), title: Text('Lunaris ${info.version} is available'),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -454,7 +454,7 @@ class _SettingsContent extends ConsumerWidget {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('RuleGel changelog'), title: const Text('Lunaris changelog'),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -462,7 +462,7 @@ class _SettingsContent extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
for (final change in ruleGelChangelog) ...[ for (final change in lunarisChangelog) ...[
Text( Text(
'${change.version} - ${change.title}', '${change.version} - ${change.title}',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
+2 -2
View File
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "gel_rule_app"); gtk_window_set_title(window, "Lunaris");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 1.0.2+10 version: 1.1.0+11
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+42
View File
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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/models/post.dart';
import 'package:gel_rule_app/backend/repositories/collection_repository.dart'; import 'package:gel_rule_app/backend/repositories/collection_repository.dart';
import 'package:gel_rule_app/backend/repositories/favorite_repository.dart'; import 'package:gel_rule_app/backend/repositories/favorite_repository.dart';
@@ -73,11 +74,52 @@ void main() {
'e621', 'e621',
'e926', 'e926',
'realbooru', 'realbooru',
'xbooru',
'cosbooru',
]); ]);
expect( expect(
providers.singleWhere((provider) => provider.id == 'realbooru').enabled, providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
isFalse, 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 { test('settings saveEnabledProviders updates provider configs', () async {
+37 -1
View File
@@ -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/moebooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/rule34_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/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/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart'; import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
@@ -205,7 +206,7 @@ void main() {
expect(posts.single.rating, 'unknown'); expect(posts.single.rating, 'unknown');
}); });
test('seed providers include enabled Realbooru', () { test('seed providers include CosBooru and no Paheal', () {
final realbooru = ProviderRepository.seedProviders() final realbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'realbooru') .where((provider) => provider.id == 'realbooru')
.single; .single;
@@ -213,6 +214,23 @@ void main() {
expect(realbooru.enabled, isFalse); expect(realbooru.enabled, isFalse);
expect(realbooru.apiType, 'realbooru'); expect(realbooru.apiType, 'realbooru');
expect(realbooru.baseUrl, 'https://realbooru.com'); 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', () { test('provider factory creates Realbooru provider', () {
@@ -232,4 +250,22 @@ void main() {
expect(provider, isA<RealbooruProvider>()); expect(provider, isA<RealbooruProvider>());
}); });
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<DanbooruProvider>());
});
} }
+3 -3
View File
@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.example" "\0" VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "RuleGel" "\0" VALUE "FileDescription", "Lunaris" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\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 "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "gel_rule_app.exe" "\0" VALUE "OriginalFilename", "gel_rule_app.exe" "\0"
VALUE "ProductName", "RuleGel" "\0" VALUE "ProductName", "Lunaris" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"gel_rule_app", origin, size)) { if (!window.Create(L"Lunaris", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);