Compare commits

..
6 Commits
Author SHA1 Message Date
RarDog c7f507836b fix: fullscreen playback state retention and responsive control buttons (v3.1.8)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:33:42 +03:00
RarDog 1395cfd414 fix: remove unsafe libmpv setProperty FFI calls causing SIGSEGV crash (v3.1.7)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:17:15 +03:00
RarDog 15bebd6e47 fix: fullscreen play-pause button, search clear providers, video volume persistence & codec fallback (v3.1.6)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:09:23 +03:00
RarDog 295499ca4b feat: persist video volume, fix fullscreen controls, search providers & comments headroom (v3.1.5)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 22:50:15 +03:00
RarDog 9a0fd282b7 feat(video): optimize video player aspect ratio sizing and seekbar positioning v3.1.4
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 21:50:17 +03:00
RarDog 254f0e3d59 feat: Liquid Glass redesign for providers and network check, and tab back navigation (v3.1.3)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 21:29:25 +03:00
20 changed files with 3320 additions and 512 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.2-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.2" /></a> <a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.8-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.8" /></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/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-00C853?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-00C853?style=for-the-badge" alt="Platforms" />
<a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a> <a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '3.1.2'; const appDisplayVersion = '3.1.8';
const appBuildNumber = 63; const appBuildNumber = 69;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+59
View File
@@ -1,4 +1,63 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '3.1.8',
title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления',
bullets: [
'Непрерывное воспроизведение при открытии на весь экран: видео больше не останавливается самопроизвольно при входе в полноэкранный режим или смене ориентации устройства.',
'Корректный статус воспроизведения: устранен сброс кнопки на «Пуск» при переходе в полный экран — видео продолжает воспроизведение без ложных остановок.',
'Мгновенный отклик кнопок «Пуск/Пауза» и перемотки: жесты управления поверхностью (двойной тап, смахивание громкости) перемещены в фоновый слой, исключая перехват и отмену нажатий по кнопкам управления.',
'Синхронизация состояния при сворачивании: плеер точно запоминает состояние паузы/воспроизведения при выходе из полноэкранного режима обратно в пост.',
],
),
LunarisChange(
version: '3.1.7',
title: 'Устранение критического сбоя при открытии видеопоста (SIGSEGV в libmpv)',
bullets: [
'Устранение нативного вылета: полностью удалены небезопасные прямые FFI-вызовы setProperty к mpv, приводившие к немедленному падению приложения на Android при инициализации видео.',
'Безопасная конфигурация декодирования: перевод настроек аппаратного ускорения и фоллбэка кодеков на официальный типобезопасный VideoControllerConfiguration (media_kit_video).',
'Надежный фоллбэк при редких ошибках кодека: при несовместимости аппаратного декодера плеер бесшовно переключается на программный FFmpeg-декодер без риска краша.',
],
),
LunarisChange(
version: '3.1.6',
title: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
bullets: [
'Полноэкранный плеер — отзывчивая кнопка Пуск/Пауза: центральная кнопка управления воспроизведением полностью переработана на непроницаемый GestureDetector со сквиркл-откликом, гарантируя мгновенное срабатывание без перехвата внешними жестами.',
'Сохранение источников при нажатии на крестик в поиске: клик по кнопке сброса в строке поиска теперь очищает только поисковый запрос и теги, никогда не сбрасывая выбранные источники до «Все».',
'Безупречное сохранение громкости: громкость видеоплеера корректно инициализируется из настроек, сохраняется при регулировках жестом и переходе между постами, не сбрасываясь на 100% при открытии нового видео и перезапуске приложения.',
'Автоматический fallback для ошибки Could not open codec: видеоплеер получил безопасные профили декодирования и автопереключение на программный лавк-декодер (FFmpeg software fallback) при редких сбоях аппаратного ускорения.',
],
),
LunarisChange(
version: '3.1.5',
title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев',
bullets: [
'Сохранение уровня громкости (Persistent Volume): выбранная громкость видеоплеера теперь сохраняется при свайп-регулировке и переключениях между видео и сохраняется даже после перезапуска приложения.',
'Исправление кнопок в полноэкранном режиме: кнопки паузы/воспроизведения и быстрой перемотки на ±10 секунд в центре экрана теперь работают мгновенно и надежно с анимацией визуального отклика (Seek Ripple).',
'Сохранение выбранных источников при очистке тегов: удаление всех тегов в строке поиска больше не сбрасывает выбранные провайдеры до значения «Все», сохраняя ваши активные источники.',
'Плавная строка поиска: при выборе тега из подсказок поле ввода больше не сдвигается слишком далеко в сторону, оставляя тег и курсор в поле зрения.',
'Удобный доступ к комментариям в посте: увеличен отступ снизу в деталях поста и расширена зона клика блока комментариев, позволяя комфортно прокрутить их выше системных жестов навигации.',
],
),
LunarisChange(
version: '3.1.4',
title: 'Оптимизация видеоплеера — адаптивная высота и идеальное прилегание полосы воспроизведения',
bullets: [
'Устранение зазора под видео: контейнер плеера на мобильных устройствах и ПК теперь динамически подстраивается под пропорции видео (aspect ratio) вместо жестко фиксированной высоты 62% экрана, устраняя огромные пустые черные полосы сверху и снизу.',
'Точное позиционирование полосы воспроизведения: исправлен отступ снизу и ложное наложение системных insets на неполноэкранный плеер в карточке поста — ползунок воспроизведения, таймкоды и кнопка фуллскрина теперь аккуратно прилегают к нижнему краю видео без зависания в воздухе.',
'Компактные элементы управления: выровнены буферный трек и слайдер прокрутки, оптимизирован градиент затенения для максимальной видимости и удобства управления.',
],
),
LunarisChange(
version: '3.1.3',
title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам',
bullets: [
'Возврат назад по вкладкам (Tab Back Navigation): реализован стек истории переходов между разделами в AppShell — теперь при нажатии кнопки «Назад» или системного жеста происходит возврат к ранее открытым вкладкам и подразделам вместо случайного закрытия приложения.',
'Liquid Glass редизайн списка источников (Providers): стеклянные карточки провайдеров с красивыми сквиркл-иконками движков (Gelbooru, Rule34, Danbooru, e621, Yande.re, Paheal, Kemono), переключателями активности, инфо-бейджами приоритета и зеркальными кнопками управления.',
'Liquid Glass редизайн редактирования и создания источников (Edit / New Provider): секционные стеклянные карточки с градиентными бейджами, полоса быстрых пресетов для мгновенного заполнения URL и типа API, безопасный ввод API-ключей и наглядные подсказки.',
'Liquid Glass редизайн проверки состояния источников (Provider Check): карточка общих метрик сети (онлайн/офлайн, средний пинг), раскрывающиеся аккордеоны детальной диагностики, живые светодиодные LED-индикаторы статуса и кнопка одновременной проверки всех провайдеров.',
],
),
LunarisChange( LunarisChange(
version: '3.1.2', version: '3.1.2',
title: 'Оптимизация производительности: устранение лагов при скролле к тегам', title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
@@ -39,6 +39,7 @@ class AppSettings {
required this.motionRefreshMode, required this.motionRefreshMode,
required this.autoBatterySaver60Hz, required this.autoBatterySaver60Hz,
required this.videoPlayerMuted, required this.videoPlayerMuted,
this.videoPlayerVolume = 100.0,
required this.videoPlayerHalfVolume, required this.videoPlayerHalfVolume,
required this.videoPlayerLoop, required this.videoPlayerLoop,
required this.videoPlayerCover, required this.videoPlayerCover,
@@ -94,6 +95,7 @@ class AppSettings {
final String motionRefreshMode; final String motionRefreshMode;
final bool autoBatterySaver60Hz; final bool autoBatterySaver60Hz;
final bool videoPlayerMuted; final bool videoPlayerMuted;
final double videoPlayerVolume;
final bool videoPlayerHalfVolume; final bool videoPlayerHalfVolume;
final bool videoPlayerLoop; final bool videoPlayerLoop;
final bool videoPlayerCover; final bool videoPlayerCover;
@@ -146,6 +148,7 @@ class AppSettings {
motionRefreshMode: 'auto', motionRefreshMode: 'auto',
autoBatterySaver60Hz: true, autoBatterySaver60Hz: true,
videoPlayerMuted: false, videoPlayerMuted: false,
videoPlayerVolume: 100.0,
videoPlayerHalfVolume: false, videoPlayerHalfVolume: false,
videoPlayerLoop: false, videoPlayerLoop: false,
videoPlayerCover: false, videoPlayerCover: false,
@@ -191,6 +194,7 @@ class AppSettings {
String? motionRefreshMode, String? motionRefreshMode,
bool? autoBatterySaver60Hz, bool? autoBatterySaver60Hz,
bool? videoPlayerMuted, bool? videoPlayerMuted,
double? videoPlayerVolume,
bool? videoPlayerHalfVolume, bool? videoPlayerHalfVolume,
bool? videoPlayerLoop, bool? videoPlayerLoop,
bool? videoPlayerCover, bool? videoPlayerCover,
@@ -252,6 +256,7 @@ class AppSettings {
motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode, motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode,
autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz, autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz,
videoPlayerMuted: videoPlayerMuted ?? this.videoPlayerMuted, videoPlayerMuted: videoPlayerMuted ?? this.videoPlayerMuted,
videoPlayerVolume: videoPlayerVolume ?? this.videoPlayerVolume,
videoPlayerHalfVolume: videoPlayerHalfVolume:
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume, videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop, videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
@@ -309,6 +314,7 @@ class AppSettings {
'motionRefreshMode': motionRefreshMode, 'motionRefreshMode': motionRefreshMode,
'autoBatterySaver60Hz': autoBatterySaver60Hz, 'autoBatterySaver60Hz': autoBatterySaver60Hz,
'videoPlayerMuted': videoPlayerMuted, 'videoPlayerMuted': videoPlayerMuted,
'videoPlayerVolume': videoPlayerVolume,
'videoPlayerHalfVolume': videoPlayerHalfVolume, 'videoPlayerHalfVolume': videoPlayerHalfVolume,
'videoPlayerLoop': videoPlayerLoop, 'videoPlayerLoop': videoPlayerLoop,
'videoPlayerCover': videoPlayerCover, 'videoPlayerCover': videoPlayerCover,
@@ -392,6 +398,8 @@ class AppSettings {
defaults.autoBatterySaver60Hz, defaults.autoBatterySaver60Hz,
videoPlayerMuted: videoPlayerMuted:
(json['videoPlayerMuted'] as bool?) ?? defaults.videoPlayerMuted, (json['videoPlayerMuted'] as bool?) ?? defaults.videoPlayerMuted,
videoPlayerVolume: (json['videoPlayerVolume'] as num?)?.toDouble() ??
defaults.videoPlayerVolume,
videoPlayerHalfVolume: (json['videoPlayerHalfVolume'] as bool?) ?? videoPlayerHalfVolume: (json['videoPlayerHalfVolume'] as bool?) ??
defaults.videoPlayerHalfVolume, defaults.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop:
@@ -601,6 +609,17 @@ class SettingsService {
return updateSettings(settings.copyWith(videoPlaybackPositions: positions)); return updateSettings(settings.copyWith(videoPlaybackPositions: positions));
} }
Future<Result<void>> setVideoPlayerVolume(double volume) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final clamped = volume.clamp(0.0, 100.0);
if ((settings.videoPlayerVolume - clamped).abs() < 0.01) {
return const Success(null);
}
return updateSettings(settings.copyWith(videoPlayerVolume: clamped));
}
Future<Result<void>> appendDiagnosticLog(String message) async { Future<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings(); final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure); if (result is Error<AppSettings>) return Error(result.failure);
@@ -374,7 +374,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void _submitSearch(String query) { void _submitSearch(String query) {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {
_clearFilters(); if (widget.initialQuery != null) {
context.go('/');
} else {
_applySearchQuery('');
}
return; return;
} }
final location = '/?q=${Uri.encodeQueryComponent(trimmed)}'; final location = '/?q=${Uri.encodeQueryComponent(trimmed)}';
@@ -104,7 +104,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
}, },
onCleared: () { onCleared: () {
_query = ''; _query = '';
widget.onClearFilters(); widget.onSearch('');
}, },
), ),
), ),
@@ -235,7 +235,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
}, },
onCleared: () { onCleared: () {
_query = ''; _query = '';
widget.onClearFilters(); widget.onSearch('');
}, },
), ),
), ),
@@ -21,6 +21,7 @@ import '../../../shared/widgets/rating_badge.dart';
import '../../collections/presentation/collection_form_dialog.dart'; import '../../collections/presentation/collection_form_dialog.dart';
import '../../favorites/presentation/favorites_controller.dart'; import '../../favorites/presentation/favorites_controller.dart';
import '../../feed/presentation/feed_controller.dart'; import '../../feed/presentation/feed_controller.dart';
import '../../settings/presentation/settings_controller.dart';
import '../../viewed/presentation/viewed_controller.dart'; import '../../viewed/presentation/viewed_controller.dart';
import 'post_details_controller.dart'; import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart'; import 'widgets/cloud_mirrors_card.dart';
@@ -219,34 +220,79 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
Expanded( Expanded(
child: Center( child: Center(
child: SizedBox( child: MediaUrlSelector.isVideo(post)
width: double.infinity, ? ConstrainedBox(
height: 760, constraints: const BoxConstraints(
child: PostMediaViewer( maxHeight: 760,
key: ValueKey(post.cacheKey), ),
post: post, child: AspectRatio(
localFilePath: localMedia?.savedPath, aspectRatio:
qualityMode: qualityMode, (post.width > 0 && post.height > 0)
mediaHeaders: ref ? (post.width / post.height)
.watch(postMediaHeadersProvider(post)) .clamp(0.45, 2.4)
.value ?? : (16 / 9),
const {}, child: PostMediaViewer(
initialPosition: Duration( key: ValueKey(post.cacheKey),
milliseconds: settings.videoPlaybackPositions[ post: post,
post.cacheKey] ?? localFilePath: localMedia?.savedPath,
0, qualityMode: qualityMode,
), mediaHeaders: ref
initialLoop: settings.videoPlayerLoop, .watch(postMediaHeadersProvider(post))
initialMuted: settings.videoPlayerMuted, .value ??
initialCoverVideo: settings.videoPlayerCover, const {},
initialHalfVolume: initialPosition: Duration(
settings.videoPlayerHalfVolume, milliseconds: settings.videoPlaybackPositions[
onPlaybackSnapshot: (snapshot) => post.cacheKey] ??
_saveVideoSnapshot(ref, post, snapshot), 0,
onPlaybackPreferencesChanged: (snapshot) => ),
_saveVideoPreferences(ref, snapshot), initialLoop: settings.videoPlayerLoop,
), initialMuted: settings.videoPlayerMuted,
), initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume:
settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
),
),
)
: SizedBox(
width: double.infinity,
height: 760,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode,
mediaHeaders: ref
.watch(postMediaHeadersProvider(post))
.value ??
const {},
initialPosition: Duration(
milliseconds: settings.videoPlaybackPositions[
post.cacheKey] ??
0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume:
settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
),
),
), ),
), ),
IconButton.filledTonal( IconButton.filledTonal(
@@ -355,6 +401,7 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_CommentsSection(post: post), _CommentsSection(post: post),
const SizedBox(height: 80),
], ],
), ),
), ),
@@ -399,7 +446,12 @@ class PostDetailsScreen extends ConsumerWidget {
if ((details.primaryVelocity ?? 0) > 900) _close(context); if ((details.primaryVelocity ?? 0) > 900) _close(context);
}, },
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), padding: EdgeInsets.fromLTRB(
8,
8,
8,
120 + MediaQuery.paddingOf(context).bottom,
),
children: [ children: [
GestureDetector( GestureDetector(
behavior: HitTestBehavior.deferToChild, behavior: HitTestBehavior.deferToChild,
@@ -409,33 +461,80 @@ class PostDetailsScreen extends ConsumerWidget {
? null ? null
: () => : () =>
_showMobileQuickActions(context, ref, post, favoriteKeys), _showMobileQuickActions(context, ref, post, favoriteKeys),
child: SizedBox( child: isVideo
width: double.infinity, ? Center(
height: MediaQuery.sizeOf(context).height * 0.62, child: ConstrainedBox(
child: PostMediaViewer( constraints: BoxConstraints(
key: ValueKey(post.cacheKey), maxHeight: MediaQuery.sizeOf(context).height * 0.70,
post: post, ),
localFilePath: localMedia?.savedPath, child: AspectRatio(
qualityMode: qualityMode, aspectRatio: (post.width > 0 && post.height > 0)
mediaHeaders: ? (post.width / post.height).clamp(0.45, 2.4)
ref.watch(postMediaHeadersProvider(post)).value ?? const {}, : (16 / 9),
initialPosition: Duration( child: PostMediaViewer(
milliseconds: key: ValueKey(post.cacheKey),
settings.videoPlaybackPositions[post.cacheKey] ?? 0, post: post,
), localFilePath: localMedia?.savedPath,
initialLoop: settings.videoPlayerLoop, qualityMode: qualityMode,
initialMuted: settings.videoPlayerMuted, mediaHeaders:
initialCoverVideo: settings.videoPlayerCover, ref.watch(postMediaHeadersProvider(post)).value ??
initialHalfVolume: settings.videoPlayerHalfVolume, const {},
onPlaybackSnapshot: (snapshot) => initialPosition: Duration(
_saveVideoSnapshot(ref, post, snapshot), milliseconds:
onPlaybackPreferencesChanged: (snapshot) => settings.videoPlaybackPositions[post.cacheKey] ??
_saveVideoPreferences(ref, snapshot), 0,
onMediaGestureLockChanged: (locked) { ),
onMediaGestureLockChanged?.call(locked); initialLoop: settings.videoPlayerLoop,
}, initialMuted: settings.videoPlayerMuted,
), initialCoverVideo: settings.videoPlayerCover,
), initialHalfVolume: settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
onMediaGestureLockChanged: (locked) {
onMediaGestureLockChanged?.call(locked);
},
),
),
),
)
: SizedBox(
width: double.infinity,
height: MediaQuery.sizeOf(context).height * 0.62,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode,
mediaHeaders:
ref.watch(postMediaHeadersProvider(post)).value ??
const {},
initialPosition: Duration(
milliseconds:
settings.videoPlaybackPositions[post.cacheKey] ?? 0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
onMediaGestureLockChanged: (locked) {
onMediaGestureLockChanged?.call(locked);
},
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
PostActionBar( PostActionBar(
@@ -533,6 +632,7 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_CommentsSection(post: post), _CommentsSection(post: post),
const SizedBox(height: 24),
], ],
), ),
); );
@@ -779,7 +879,8 @@ class PostDetailsScreen extends ConsumerWidget {
if (settings.videoPlayerMuted == snapshot.muted && if (settings.videoPlayerMuted == snapshot.muted &&
settings.videoPlayerHalfVolume == snapshot.halfVolume && settings.videoPlayerHalfVolume == snapshot.halfVolume &&
settings.videoPlayerLoop == snapshot.loopVideo && settings.videoPlayerLoop == snapshot.loopVideo &&
settings.videoPlayerCover == snapshot.coverVideo) { settings.videoPlayerCover == snapshot.coverVideo &&
(settings.videoPlayerVolume - snapshot.volume).abs() < 0.01) {
return; return;
} }
await ref.read(settingsServiceProvider).updateSettings( await ref.read(settingsServiceProvider).updateSettings(
@@ -788,6 +889,7 @@ class PostDetailsScreen extends ConsumerWidget {
videoPlayerHalfVolume: snapshot.halfVolume, videoPlayerHalfVolume: snapshot.halfVolume,
videoPlayerLoop: snapshot.loopVideo, videoPlayerLoop: snapshot.loopVideo,
videoPlayerCover: snapshot.coverVideo, videoPlayerCover: snapshot.coverVideo,
videoPlayerVolume: snapshot.volume,
), ),
); );
ref.invalidate(appSettingsProvider); ref.invalidate(appSettingsProvider);
@@ -1084,7 +1186,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ExpansionTile( child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14), childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: false, initiallyExpanded: false,
shape: const Border(), shape: const Border(),
@@ -27,10 +27,12 @@ class PostMediaViewer extends StatefulWidget {
this.initialMuted = false, this.initialMuted = false,
this.initialCoverVideo = false, this.initialCoverVideo = false,
this.initialHalfVolume = false, this.initialHalfVolume = false,
this.initialVolume = 100.0,
this.qualityMode = MediaQualityMode.auto, this.qualityMode = MediaQualityMode.auto,
this.mediaHeaders = const {}, this.mediaHeaders = const {},
this.onPlaybackSnapshot, this.onPlaybackSnapshot,
this.onPlaybackPreferencesChanged, this.onPlaybackPreferencesChanged,
this.onVolumeChanged,
this.onMediaGestureLockChanged, this.onMediaGestureLockChanged,
super.key, super.key,
}); });
@@ -44,10 +46,12 @@ class PostMediaViewer extends StatefulWidget {
final bool initialMuted; final bool initialMuted;
final bool initialCoverVideo; final bool initialCoverVideo;
final bool initialHalfVolume; final bool initialHalfVolume;
final double initialVolume;
final MediaQualityMode qualityMode; final MediaQualityMode qualityMode;
final Map<String, String> mediaHeaders; final Map<String, String> mediaHeaders;
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot; final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackPreferencesChanged; final ValueChanged<VideoPlaybackSnapshot>? onPlaybackPreferencesChanged;
final ValueChanged<double>? onVolumeChanged;
final ValueChanged<bool>? onMediaGestureLockChanged; final ValueChanged<bool>? onMediaGestureLockChanged;
@override @override
@@ -68,8 +72,10 @@ class _PostMediaViewerState extends State<PostMediaViewer>
late bool _muted; late bool _muted;
late bool _loopVideo; late bool _loopVideo;
late bool _halfVolume; late bool _halfVolume;
late double _currentVolume;
String? _videoError; String? _videoError;
bool _retriedFormatError = false; bool _retriedFormatError = false;
bool _softwareDecodingFallback = false;
Timer? _hideTimer; Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription; StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription; StreamSubscription<Duration>? _positionSubscription;
@@ -83,6 +89,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_muted = widget.initialMuted; _muted = widget.initialMuted;
_loopVideo = widget.initialLoop; _loopVideo = widget.initialLoop;
_halfVolume = widget.initialHalfVolume; _halfVolume = widget.initialHalfVolume;
_currentVolume = widget.initialVolume;
_imageUrls = _buildImageUrls(widget.post); _imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post);
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
@@ -99,6 +106,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0; _videoIndex = 0;
_videoError = null; _videoError = null;
_retriedFormatError = false; _retriedFormatError = false;
_softwareDecodingFallback = false;
_imageUrls = _buildImageUrls(widget.post); _imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post);
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
@@ -109,6 +117,27 @@ class _PostMediaViewerState extends State<PostMediaViewer>
unawaited(_retryVideo()); unawaited(_retryVideo());
} }
} }
if (oldWidget.initialVolume != widget.initialVolume) {
_currentVolume = widget.initialVolume;
unawaited(_applyVolume());
}
if (oldWidget.initialMuted != widget.initialMuted) {
_muted = widget.initialMuted;
unawaited(_applyVolume());
}
if (oldWidget.initialHalfVolume != widget.initialHalfVolume) {
_halfVolume = widget.initialHalfVolume;
unawaited(_applyVolume());
}
if (oldWidget.initialLoop != widget.initialLoop) {
_loopVideo = widget.initialLoop;
unawaited(_player?.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
));
}
if (oldWidget.initialCoverVideo != widget.initialCoverVideo) {
_coverVideo = widget.initialCoverVideo;
}
} }
@override @override
@@ -167,6 +196,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
muted: _muted, muted: _muted,
loopVideo: _loopVideo, loopVideo: _loopVideo,
halfVolume: _halfVolume, halfVolume: _halfVolume,
initialVolume: _currentVolume,
onVolumeChanged: (vol) {
_currentVolume = vol;
widget.onVolumeChanged?.call(vol);
_emitPlaybackPreferences();
},
fullscreen: widget.fullscreen, fullscreen: widget.fullscreen,
errorMessage: _videoError, errorMessage: _videoError,
onInteract: _showControls, onInteract: _showControls,
@@ -326,7 +361,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() { void _initializeVideo() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
_player = Player(); _player = Player();
_controller = VideoController(_player!); _controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
),
);
_applyVolume(); _applyVolume();
_player!.setPlaylistMode( _player!.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none, _loopVideo ? PlaylistMode.single : PlaylistMode.none,
@@ -370,6 +411,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
), ),
play: false, play: false,
); );
await _applyVolume();
if (initialPosition > Duration.zero) { if (initialPosition > Duration.zero) {
await player.seek(initialPosition); await player.seek(initialPosition);
} }
@@ -385,18 +427,24 @@ class _PostMediaViewerState extends State<PostMediaViewer>
await _openVideo(play: play); await _openVideo(play: play);
return; return;
} }
// If format error occurred on initial load (often due to async referer headers), retry once automatically final lower = message.toLowerCase();
// If format or codec error occurred (e.g. unsupported hwdec profile), fallback to software decoding and retry once
if (!_retriedFormatError && if (!_retriedFormatError &&
(message.contains('Failed to recognize file format') || (lower.contains('codec') ||
message.contains('Demuxer error') || lower.contains('could not open') ||
message.contains('Could not open') || lower.contains('couldnt open') ||
message.contains('Stream format not recognized') || lower.contains('could not initialize codec') ||
message.contains('format unrecognized'))) { lower.contains('demuxer error') ||
lower.contains('format unrecognized') ||
lower.contains('stream format not recognized') ||
lower.contains('failed to recognize file format'))) {
_retriedFormatError = true; _retriedFormatError = true;
await Future<void>.delayed(const Duration(milliseconds: 300)); _softwareDecodingFallback = true;
if (mounted && _player != null) { _disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) {
_videoIndex = 0; _videoIndex = 0;
await _openVideo(play: play); _initializeVideo();
return; return;
} }
} }
@@ -408,7 +456,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
Future<void> _retryVideo() async { Future<void> _retryVideo() async {
_videoIndex = 0; _videoIndex = 0;
_retriedFormatError = false; _retriedFormatError = false;
await _openVideo(play: true); _softwareDecodingFallback = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
_initializeVideo();
}
} }
void _disposeVideo() { void _disposeVideo() {
@@ -518,6 +571,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
final player = _player; final player = _player;
final controller = _controller; final controller = _controller;
if (player == null || controller == null) return; if (player == null || controller == null) return;
final wasPlaying = player.state.playing;
setState(() => _inFullscreen = true); setState(() => _inFullscreen = true);
if (!context.mounted) return; if (!context.mounted) return;
final result = await Navigator.of(context, rootNavigator: true) final result = await Navigator.of(context, rootNavigator: true)
@@ -527,6 +581,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
pageBuilder: (_, __, ___) => _FullscreenVideoPage( pageBuilder: (_, __, ___) => _FullscreenVideoPage(
player: player, player: player,
controller: controller, controller: controller,
wasPlaying: wasPlaying,
aspectRatio: widget.post.width > 0 && widget.post.height > 0 aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height ? widget.post.width / widget.post.height
: 16 / 9, : 16 / 9,
@@ -534,6 +589,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
muted: _muted, muted: _muted,
halfVolume: _halfVolume, halfVolume: _halfVolume,
coverVideo: _coverVideo, coverVideo: _coverVideo,
initialVolume: _currentVolume,
errorMessage: _videoError, errorMessage: _videoError,
onRetry: _retryVideo, onRetry: _retryVideo,
onChanged: (snapshot) { onChanged: (snapshot) {
@@ -549,6 +605,11 @@ class _PostMediaViewerState extends State<PostMediaViewer>
if (mounted) setState(() => _inFullscreen = false); if (mounted) setState(() => _inFullscreen = false);
if (result != null) { if (result != null) {
await _applyPlaybackSnapshot(result); await _applyPlaybackSnapshot(result);
if (result.playing && !player.state.playing) {
await player.play();
} else if (!result.playing && player.state.playing) {
await player.pause();
}
return; return;
} }
} }
@@ -560,12 +621,14 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_loopVideo = snapshot.loopVideo; _loopVideo = snapshot.loopVideo;
_coverVideo = snapshot.coverVideo; _coverVideo = snapshot.coverVideo;
_halfVolume = snapshot.halfVolume; _halfVolume = snapshot.halfVolume;
_currentVolume = snapshot.volume;
}); });
await _applyVolume(); await _applyVolume();
await _player?.setPlaylistMode( await _player?.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none, _loopVideo ? PlaylistMode.single : PlaylistMode.none,
); );
_emitPlaybackPreferences(); _emitPlaybackPreferences();
widget.onVolumeChanged?.call(_currentVolume);
} }
VideoPlaybackSnapshot _snapshot() { VideoPlaybackSnapshot _snapshot() {
@@ -577,15 +640,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
halfVolume: _halfVolume, halfVolume: _halfVolume,
loopVideo: _loopVideo, loopVideo: _loopVideo,
coverVideo: _coverVideo, coverVideo: _coverVideo,
volume: _currentVolume,
); );
} }
Future<void> _applyVolume() async { Future<void> _applyVolume() async {
await _player?.setVolume(_muted await _player?.setVolume(_muted
? 0 ? 0.0
: _halfVolume : _halfVolume
? 50 ? 50.0
: 100); : _currentVolume);
} }
void _emitPlaybackPreferences() { void _emitPlaybackPreferences() {
@@ -656,6 +720,7 @@ class VideoPlaybackSnapshot {
required this.halfVolume, required this.halfVolume,
required this.loopVideo, required this.loopVideo,
required this.coverVideo, required this.coverVideo,
this.volume = 100.0,
}); });
final Duration position; final Duration position;
@@ -664,17 +729,20 @@ class VideoPlaybackSnapshot {
final bool halfVolume; final bool halfVolume;
final bool loopVideo; final bool loopVideo;
final bool coverVideo; final bool coverVideo;
final double volume;
} }
class _FullscreenVideoPage extends StatefulWidget { class _FullscreenVideoPage extends StatefulWidget {
const _FullscreenVideoPage({ const _FullscreenVideoPage({
required this.player, required this.player,
required this.controller, required this.controller,
required this.wasPlaying,
required this.aspectRatio, required this.aspectRatio,
required this.loopVideo, required this.loopVideo,
required this.muted, required this.muted,
required this.halfVolume, required this.halfVolume,
required this.coverVideo, required this.coverVideo,
this.initialVolume = 100.0,
required this.errorMessage, required this.errorMessage,
required this.onRetry, required this.onRetry,
required this.onChanged, required this.onChanged,
@@ -682,11 +750,13 @@ class _FullscreenVideoPage extends StatefulWidget {
final Player player; final Player player;
final VideoController controller; final VideoController controller;
final bool wasPlaying;
final double aspectRatio; final double aspectRatio;
final bool loopVideo; final bool loopVideo;
final bool muted; final bool muted;
final bool halfVolume; final bool halfVolume;
final bool coverVideo; final bool coverVideo;
final double initialVolume;
final String? errorMessage; final String? errorMessage;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged; final ValueChanged<VideoPlaybackSnapshot> onChanged;
@@ -701,6 +771,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
late bool _muted; late bool _muted;
late bool _loopVideo; late bool _loopVideo;
late bool _halfVolume; late bool _halfVolume;
late double _currentVolume;
late bool _isLandscape; late bool _isLandscape;
Timer? _hideTimer; Timer? _hideTimer;
@@ -711,10 +782,19 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
_muted = widget.muted; _muted = widget.muted;
_loopVideo = widget.loopVideo; _loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume; _halfVolume = widget.halfVolume;
_currentVolume = widget.initialVolume;
_applyVolume();
_isLandscape = widget.aspectRatio > 1.05; _isLandscape = widget.aspectRatio > 1.05;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_applyOrientation(); _applyOrientation();
_scheduleControlsHide(); _scheduleControlsHide();
if (widget.wasPlaying) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !widget.player.state.playing) {
widget.player.play();
}
});
}
} }
void _applyOrientation() { void _applyOrientation() {
@@ -790,6 +870,11 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
muted: _muted, muted: _muted,
loopVideo: _loopVideo, loopVideo: _loopVideo,
halfVolume: _halfVolume, halfVolume: _halfVolume,
initialVolume: _currentVolume,
onVolumeChanged: (vol) {
_currentVolume = vol;
widget.onChanged(_snapshot());
},
fullscreen: true, fullscreen: true,
errorMessage: widget.errorMessage, errorMessage: widget.errorMessage,
onInteract: _showControls, onInteract: _showControls,
@@ -883,10 +968,10 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
Future<void> _applyVolume() async { Future<void> _applyVolume() async {
await widget.player.setVolume(_muted await widget.player.setVolume(_muted
? 0 ? 0.0
: _halfVolume : _halfVolume
? 50 ? 50.0
: 100); : _currentVolume);
} }
VideoPlaybackSnapshot _snapshot() { VideoPlaybackSnapshot _snapshot() {
@@ -897,6 +982,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
halfVolume: _halfVolume, halfVolume: _halfVolume,
loopVideo: _loopVideo, loopVideo: _loopVideo,
coverVideo: _coverVideo, coverVideo: _coverVideo,
volume: _currentVolume,
); );
} }
} }
@@ -915,6 +1001,8 @@ class _VideoSurface extends StatefulWidget {
required this.muted, required this.muted,
required this.halfVolume, required this.halfVolume,
required this.loopVideo, required this.loopVideo,
this.initialVolume = 100.0,
this.onVolumeChanged,
required this.fullscreen, required this.fullscreen,
required this.errorMessage, required this.errorMessage,
required this.onInteract, required this.onInteract,
@@ -934,6 +1022,8 @@ class _VideoSurface extends StatefulWidget {
final bool muted; final bool muted;
final bool halfVolume; final bool halfVolume;
final bool loopVideo; final bool loopVideo;
final double initialVolume;
final ValueChanged<double>? onVolumeChanged;
final bool fullscreen; final bool fullscreen;
final String? errorMessage; final String? errorMessage;
final VoidCallback onInteract; final VoidCallback onInteract;
@@ -965,7 +1055,17 @@ class _VideoSurfaceState extends State<_VideoSurface> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentVolume = widget.player.state.volume; _currentVolume = widget.initialVolume;
widget.player.setVolume(_currentVolume);
}
@override
void didUpdateWidget(covariant _VideoSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialVolume != widget.initialVolume) {
_currentVolume = widget.initialVolume;
widget.player.setVolume(_currentVolume);
}
} }
@override @override
@@ -983,26 +1083,37 @@ class _VideoSurfaceState extends State<_VideoSurface> {
if (target < Duration.zero) target = Duration.zero; if (target < Duration.zero) target = Duration.zero;
if (dur > Duration.zero && target > dur) target = dur; if (dur > Duration.zero && target > dur) target = dur;
widget.player.seek(target); widget.player.seek(target);
widget.onInteract();
if (delta.inSeconds < 0) {
setState(() => _showLeftSeek = true);
_seekLeftTimer?.cancel();
_seekLeftTimer = Timer(const Duration(milliseconds: 650), () {
if (mounted) setState(() => _showLeftSeek = false);
});
} else if (delta.inSeconds > 0) {
setState(() => _showRightSeek = true);
_seekRightTimer?.cancel();
_seekRightTimer = Timer(const Duration(milliseconds: 650), () {
if (mounted) setState(() => _showRightSeek = false);
});
}
} }
void _onDoubleTapAt(Offset localPosition, double width) { void _onDoubleTapAt(Offset localPosition, double width) {
if (_isLocked) return; if (_isLocked) return;
if (localPosition.dx < width * 0.4) { if (localPosition.dx < width * 0.4) {
_seekBy(const Duration(seconds: -10)); _seekBy(const Duration(seconds: -10));
setState(() => _showLeftSeek = true);
_seekLeftTimer?.cancel();
_seekLeftTimer = Timer(const Duration(milliseconds: 650), () {
if (mounted) setState(() => _showLeftSeek = false);
});
} else if (localPosition.dx > width * 0.6) { } else if (localPosition.dx > width * 0.6) {
_seekBy(const Duration(seconds: 10)); _seekBy(const Duration(seconds: 10));
setState(() => _showRightSeek = true);
_seekRightTimer?.cancel();
_seekRightTimer = Timer(const Duration(milliseconds: 650), () {
if (mounted) setState(() => _showRightSeek = false);
});
} else { } else {
widget.player.playOrPause(); if (widget.player.state.playing) {
widget.player.pause();
} else {
if (widget.player.state.completed) {
widget.player.seek(Duration.zero);
}
widget.player.play();
}
widget.onInteract(); widget.onInteract();
} }
} }
@@ -1025,6 +1136,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
final newVol = (_currentVolume - deltaY * 0.5).clamp(0.0, 100.0); final newVol = (_currentVolume - deltaY * 0.5).clamp(0.0, 100.0);
_currentVolume = newVol; _currentVolume = newVol;
widget.player.setVolume(newVol); widget.player.setVolume(newVol);
widget.onVolumeChanged?.call(newVol);
setState(() => _showVolumeIndicator = true); setState(() => _showVolumeIndicator = true);
_volumeTimer?.cancel(); _volumeTimer?.cancel();
_volumeTimer = Timer(const Duration(milliseconds: 1100), () { _volumeTimer = Timer(const Duration(milliseconds: 1100), () {
@@ -1035,6 +1147,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final player = widget.player; final player = widget.player;
Offset? lastTapDown;
final child = LayoutBuilder( final child = LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@@ -1047,6 +1160,32 @@ class _VideoSurfaceState extends State<_VideoSurface> {
controller: widget.controller, controller: widget.controller,
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain, fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
controls: null, controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => lastTapDown = details.localPosition,
onTap: widget.onInteract,
onDoubleTap: () {
if (lastTapDown != null) {
_onDoubleTapAt(
lastTapDown!,
MediaQuery.sizeOf(context).width,
);
}
},
onLongPressStart: (_) => _startSpeedBoost(),
onLongPressEnd: (_) => _stopSpeedBoost(),
onVerticalDragUpdate: (details) {
final x = details.localPosition.dx;
final width = MediaQuery.sizeOf(context).width;
if (x > width * 0.4) {
_adjustVolume(details.primaryDelta ?? 0.0);
}
},
), ),
), ),
StreamBuilder<bool>( StreamBuilder<bool>(
@@ -1094,6 +1233,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
onToggleLoop: widget.onToggleLoop, onToggleLoop: widget.onToggleLoop,
onFullscreen: widget.onFullscreen, onFullscreen: widget.onFullscreen,
onSeekBy: _seekBy, onSeekBy: _seekBy,
onInteract: widget.onInteract,
), ),
), ),
), ),
@@ -1102,36 +1242,16 @@ class _VideoSurfaceState extends State<_VideoSurface> {
}, },
); );
Offset? lastTapDown;
return MouseRegion( return MouseRegion(
onHover: (_) => widget.onInteract(), onHover: (_) => widget.onInteract(),
child: GestureDetector( child: ClipRRect(
onTapDown: (details) => lastTapDown = details.localPosition, borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
onTap: widget.onInteract, child: widget.fullscreen
onDoubleTap: () { ? SizedBox.expand(child: child)
if (lastTapDown != null) { : AspectRatio(
_onDoubleTapAt(lastTapDown!, MediaQuery.sizeOf(context).width); aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
} child: child,
}, ),
onLongPressStart: (_) => _startSpeedBoost(),
onLongPressEnd: (_) => _stopSpeedBoost(),
onVerticalDragUpdate: (details) {
final x = details.localPosition.dx;
final width = MediaQuery.sizeOf(context).width;
if (x > width * 0.4) {
_adjustVolume(details.primaryDelta ?? 0.0);
}
},
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,
),
),
), ),
); );
} }
@@ -1449,30 +1569,38 @@ class _RoundControlButton extends StatelessWidget {
: Colors.white; : Colors.white;
return Tooltip( return Tooltip(
message: tooltip, message: tooltip,
child: InkResponse( child: Material(
onTap: onPressed, color: Colors.transparent,
radius: size / 2 + 4, shape: const CircleBorder(),
child: AnimatedContainer( child: InkResponse(
duration: AppMotion.duration(context, 140), containedInkWell: true,
width: size, highlightShape: BoxShape.circle,
height: size, onTap: onPressed,
decoration: BoxDecoration( child: AnimatedContainer(
color: background, duration: AppMotion.duration(context, 140),
shape: BoxShape.circle, width: size,
border: Border.all( height: size,
color: selected decoration: BoxDecoration(
? scheme.primary.withValues(alpha: 0.4) color: background,
: Colors.white.withValues(alpha: 0.14), shape: BoxShape.circle,
border: Border.all(
color: selected
? scheme.primary.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.14),
),
boxShadow: [
if (emphasized)
BoxShadow(
color: scheme.primary.withValues(alpha: 0.45),
blurRadius: 28,
spreadRadius: 2,
),
],
),
child: Center(
child: Icon(icon, color: foreground, size: iconSize),
), ),
boxShadow: [
if (emphasized)
BoxShadow(
color: scheme.primary.withValues(alpha: 0.35),
blurRadius: 14,
),
],
), ),
child: Icon(icon, color: foreground, size: iconSize),
), ),
), ),
); );
@@ -1728,6 +1856,7 @@ class _VideoControls extends StatelessWidget {
required this.onToggleLoop, required this.onToggleLoop,
required this.onFullscreen, required this.onFullscreen,
required this.onSeekBy, required this.onSeekBy,
this.onInteract,
}); });
final Player player; final Player player;
@@ -1744,6 +1873,7 @@ class _VideoControls extends StatelessWidget {
final VoidCallback onToggleLoop; final VoidCallback onToggleLoop;
final VoidCallback onFullscreen; final VoidCallback onFullscreen;
final void Function(Duration) onSeekBy; final void Function(Duration) onSeekBy;
final VoidCallback? onInteract;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1792,7 +1922,10 @@ class _VideoControls extends StatelessWidget {
), ),
), ),
child: SafeArea( child: SafeArea(
top: fullscreen,
bottom: false, bottom: false,
left: fullscreen,
right: fullscreen,
child: Row( child: Row(
children: [ children: [
if (fullscreen) ...[ if (fullscreen) ...[
@@ -1861,34 +1994,39 @@ class _VideoControls extends StatelessWidget {
icon: Icons.replay_10_rounded, icon: Icons.replay_10_rounded,
size: 46, size: 46,
iconSize: 26, iconSize: 26,
onPressed: () => onSeekBy(const Duration(seconds: -10)), onPressed: () {
onSeekBy(const Duration(seconds: -10));
onInteract?.call();
},
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
Container( _RoundControlButton(
width: 68, tooltip: playing ? 'Пауза' : 'Воспроизведение',
height: 68, icon: playing
decoration: BoxDecoration( ? Icons.pause_rounded
shape: BoxShape.circle, : Icons.play_arrow_rounded,
color: scheme.primary.withValues(alpha: 0.94), size: 68,
boxShadow: [ iconSize: 42,
BoxShadow( emphasized: true,
color: scheme.primary.withValues(alpha: 0.45), onPressed: () async {
blurRadius: 28, try {
spreadRadius: 2, if (player.state.playing) {
), await player.pause();
], } else {
), if (player.state.completed) {
child: IconButton( await player.seek(Duration.zero);
tooltip: playing ? 'Пауза' : 'Воспроизведение', }
iconSize: 42, await player.play();
color: scheme.onPrimary, }
onPressed: player.playOrPause, } catch (_) {
icon: Icon( if (player.state.playing) {
playing await player.pause();
? Icons.pause_rounded } else {
: Icons.play_arrow_rounded, await player.play();
), }
), }
onInteract?.call();
},
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
_RoundControlButton( _RoundControlButton(
@@ -1896,7 +2034,10 @@ class _VideoControls extends StatelessWidget {
icon: Icons.forward_10_rounded, icon: Icons.forward_10_rounded,
size: 46, size: 46,
iconSize: 26, iconSize: 26,
onPressed: () => onSeekBy(const Duration(seconds: 10)), onPressed: () {
onSeekBy(const Duration(seconds: 10));
onInteract?.call();
},
), ),
], ],
); );
@@ -1910,7 +2051,12 @@ class _VideoControls extends StatelessWidget {
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), padding: EdgeInsets.fromLTRB(
14,
8,
14,
fullscreen ? 10 : 4,
),
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.bottomCenter,
@@ -1920,6 +2066,9 @@ class _VideoControls extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
bottom: fullscreen,
left: fullscreen,
right: fullscreen,
child: StreamBuilder<Duration>( child: StreamBuilder<Duration>(
stream: player.stream.duration, stream: player.stream.duration,
initialData: player.state.duration, initialData: player.state.duration,
@@ -1967,8 +2116,8 @@ class _VideoControls extends StatelessWidget {
// Buffer progress track // Buffer progress track
if (maxMs > 0 && bufferMs > 0) if (maxMs > 0 && bufferMs > 0)
Positioned( Positioned(
left: 20, left: 14,
right: 20, right: 14,
child: ClipRRect( child: ClipRRect(
borderRadius: borderRadius:
BorderRadius.circular(2), BorderRadius.circular(2),
@@ -1976,10 +2125,12 @@ class _VideoControls extends StatelessWidget {
value: (bufferMs / maxMs) value: (bufferMs / maxMs)
.clamp(0.0, 1.0), .clamp(0.0, 1.0),
minHeight: 3.5, minHeight: 3.5,
backgroundColor: Colors.transparent, backgroundColor:
Colors.transparent,
valueColor: valueColor:
AlwaysStoppedAnimation( AlwaysStoppedAnimation(
Colors.white.withValues(alpha: 0.28), Colors.white
.withValues(alpha: 0.28),
), ),
), ),
), ),
@@ -1997,6 +2148,10 @@ class _VideoControls extends StatelessWidget {
const RoundSliderThumbShape( const RoundSliderThumbShape(
enabledThumbRadius: 6, enabledThumbRadius: 6,
), ),
overlayShape:
const RoundSliderOverlayShape(
overlayRadius: 14,
),
), ),
child: Slider( child: Slider(
value: valueMs, value: valueMs,
@@ -1,56 +1,334 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../backend/backend.dart'; import '../../../backend/backend.dart';
import '../../../core/utils/result.dart'; import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import 'providers_controller.dart'; import 'providers_controller.dart';
import 'widgets/provider_health_tile.dart'; import 'widgets/provider_health_tile.dart';
class ProviderCheckScreen extends ConsumerWidget { class ProviderCheckScreen extends ConsumerStatefulWidget {
const ProviderCheckScreen({super.key}); const ProviderCheckScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ProviderCheckScreen> createState() =>
_ProviderCheckScreenState();
}
class _ProviderCheckScreenState extends ConsumerState<ProviderCheckScreen> {
bool _isCheckingAll = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final providers = ref.watch(providersControllerProvider); final providers = ref.watch(providersControllerProvider);
final health = ref.watch(providerHealthProvider); final health = ref.watch(providerHealthProvider);
final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {}; final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {};
return AdaptiveScaffold( final bottomInset = MediaQuery.paddingOf(context).bottom;
title: 'Provider Check',
actions: [ return Scaffold(
IconButton( extendBodyBehindAppBar: true,
tooltip: 'Check all', appBar: PreferredSize(
onPressed: () => _checkAll(ref), preferredSize: const Size.fromHeight(60),
icon: const Icon(Icons.playlist_add_check_rounded), child: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: AppBar(
backgroundColor: isDark
? theme.colorScheme.surface.withValues(alpha: 0.75)
: Colors.white.withValues(alpha: 0.85),
surfaceTintColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/providers');
}
},
tooltip: isRu ? 'Назад' : 'Back',
),
title: Text(
isRu ? 'Проверка источников' : 'Provider Check',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: _isCheckingAll ? null : () => _checkAll(ref),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1)
.withValues(alpha: 0.35),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isCheckingAll)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
else
const Icon(
Icons.playlist_add_check_rounded,
size: 16,
color: Colors.white,
),
const SizedBox(width: 5),
Text(
isRu ? 'Проверить все' : 'Check All',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
],
),
),
), ),
], ),
body: providers.when( body: providers.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()), error: (error, _) => ErrorView(message: error.toString()),
data: (items) => ListView.builder( data: (items) {
padding: const EdgeInsets.all(16), int onlineCount = 0;
itemCount: items.length, int offlineCount = 0;
itemBuilder: (context, index) { int pingSum = 0;
final item = items[index]; int pingCount = 0;
return ProviderHealthTile(
config: item, for (final item in items) {
health: health[item.id], final h = health[item.id];
diagnostics: diagnostics[item.id], if (h != null) {
onCheck: () => _checkOne(ref, item.id), if (h.status == ProviderStatus.online) {
); onlineCount++;
}, pingSum += h.pingMs;
), pingCount++;
} else if (h.status == ProviderStatus.offline) {
offlineCount++;
}
}
}
final avgPing =
pingCount > 0 ? (pingSum / pingCount).round() : null;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: ListView(
padding: EdgeInsets.fromLTRB(
16,
MediaQuery.paddingOf(context).top + 68,
16,
140 + bottomInset,
),
children: [
// Status Overview Card
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.65),
theme.colorScheme.surfaceContainerLow
.withValues(alpha: 0.40),
]
: [
Colors.white.withValues(alpha: 0.90),
Colors.white.withValues(alpha: 0.75),
],
),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: const Color(0xFF6366F1).withValues(alpha: 0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
children: [
Text(
isRu ? 'Состояние сети' : 'Network Health',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
if (avgPing != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(
alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFF10B981).withValues(
alpha: isDark ? 0.38 : 0.25),
),
),
child: Text(
'${isRu ? 'Средний пинг' : 'Avg ping'}: $avgPing ms',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF10B981),
fontFamily: 'monospace',
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MetricPill(
label: isRu ? 'Всего' : 'Total',
value: '${items.length}',
color: theme.colorScheme.primary,
isDark: isDark,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricPill(
label: isRu ? 'Онлайн' : 'Online',
value: '$onlineCount',
color: const Color(0xFF10B981),
isDark: isDark,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricPill(
label: isRu ? 'Офлайн' : 'Offline',
value: '$offlineCount',
color: const Color(0xFFEF4444),
isDark: isDark,
),
),
],
),
],
),
),
// Health Tiles List
if (items.isEmpty)
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.40)
: Colors.white.withValues(alpha: 0.80),
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Text(
isRu ? 'Нет источников для проверки' : 'No providers to check',
style: theme.textTheme.bodyMedium,
),
),
)
else
...items.map((item) {
return ProviderHealthTile(
key: ValueKey(item.id),
config: item,
health: health[item.id],
diagnostics: diagnostics[item.id],
onCheck: () => _checkOne(ref, item.id),
);
}),
],
),
),
);
},
), ),
); );
} }
Future<void> _checkAll(WidgetRef ref) async { Future<void> _checkAll(WidgetRef ref) async {
final result = await ref.read(providerCheckServiceProvider).checkAll(); setState(() => _isCheckingAll = true);
if (result is Success<List<ProviderHealth>>) { HapticFeedback.mediumImpact();
ref.read(providerHealthProvider.notifier).state = { try {
for (final item in result.data) item.providerId: item, final result =
}; await ref.read(providerCheckServiceProvider).checkAll();
if (result is Success<List<ProviderHealth>>) {
ref.read(providerHealthProvider.notifier).state = {
for (final item in result.data) item.providerId: item,
};
}
} finally {
if (mounted) setState(() => _isCheckingAll = false);
} }
} }
@@ -65,3 +343,53 @@ class ProviderCheckScreen extends ConsumerWidget {
} }
} }
} }
class _MetricPill extends StatelessWidget {
const _MetricPill({
required this.label,
required this.value,
required this.color,
required this.isDark,
});
final String label;
final String value;
final Color color;
final bool isDark;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.30 : 0.20),
width: 1,
),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: color,
),
),
const SizedBox(height: 1),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.85),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/confirm_dialog.dart'; import '../../../shared/widgets/confirm_dialog.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import 'providers_controller.dart'; import 'providers_controller.dart';
@@ -13,50 +14,371 @@ class ProvidersScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final providers = ref.watch(providersControllerProvider); final providers = ref.watch(providersControllerProvider);
return AdaptiveScaffold( final bottomInset = MediaQuery.paddingOf(context).bottom;
title: 'Providers',
actions: [ return Scaffold(
IconButton( extendBodyBehindAppBar: true,
tooltip: 'Check providers', appBar: PreferredSize(
onPressed: () => context.go('/providers/check'), preferredSize: const Size.fromHeight(60),
icon: const Icon(Icons.network_check_rounded), child: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: AppBar(
backgroundColor: isDark
? theme.colorScheme.surface.withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.82),
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
leading: Navigator.of(context).canPop()
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
onPressed: () => context.pop(),
tooltip: isRu ? 'Назад' : 'Back',
)
: null,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(9),
boxShadow: [
BoxShadow(
color:
const Color(0xFF6366F1).withValues(alpha: 0.35),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child:
Icon(Icons.hub_rounded, size: 16, color: Colors.white),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
isRu ? 'Провайдеры' : 'Providers',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
actions: [
// Provider Check Glass Button
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
HapticFeedback.lightImpact();
context.push('/providers/check');
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.primary
.withValues(alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: theme.colorScheme.primary
.withValues(alpha: isDark ? 0.35 : 0.22),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.network_check_rounded,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 5),
Text(
isRu ? 'Проверка' : 'Check',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
),
),
],
),
),
),
),
),
// Add Provider Accent Button
Padding(
padding: const EdgeInsets.only(right: 12),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
HapticFeedback.lightImpact();
context.push('/providers/new');
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1)
.withValues(alpha: 0.35),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.add_rounded,
size: 16,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
isRu ? 'Создать' : 'Add',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
],
),
),
), ),
IconButton( ),
tooltip: 'Add provider',
onPressed: () => context.go('/providers/new'),
icon: const Icon(Icons.add_rounded),
),
],
body: providers.when( body: providers.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()), error: (error, _) => ErrorView(message: error.toString()),
data: (items) => ListView.builder( data: (items) {
padding: const EdgeInsets.all(16), final activeCount = items.where((p) => p.enabled).length;
itemCount: items.length,
itemBuilder: (context, index) { return Align(
final item = items[index]; alignment: Alignment.topCenter,
return ProviderCard( child: ConstrainedBox(
config: item, constraints: const BoxConstraints(maxWidth: 860),
onToggle: (enabled) => ref child: ListView(
.read(providersControllerProvider.notifier) padding: EdgeInsets.fromLTRB(
.toggle(item.id, enabled), 16,
onEdit: () => context.go('/providers/new', extra: item), MediaQuery.paddingOf(context).top + 68,
onDelete: () async { 16,
final ok = await showConfirmDialog( 140 + bottomInset,
context, ),
title: 'Delete provider', children: [
message: 'Remove ${item.name}?', // Overview Glass Card
); Container(
if (ok) { margin: const EdgeInsets.only(bottom: 16),
await ref padding: const EdgeInsets.all(16),
.read(providersControllerProvider.notifier) decoration: BoxDecoration(
.delete(item.id); borderRadius: BorderRadius.circular(22),
} gradient: LinearGradient(
}, begin: Alignment.topLeft,
); end: Alignment.bottomRight,
}, colors: isDark
), ? [
theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.65),
theme.colorScheme.surfaceContainerLow
.withValues(alpha: 0.40),
]
: [
Colors.white.withValues(alpha: 0.90),
Colors.white.withValues(alpha: 0.75),
],
),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: const Color(0xFF6366F1).withValues(alpha: 0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isRu
? 'Управление источниками'
: 'Provider Management',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
const SizedBox(height: 4),
Text(
isRu
? 'Настройте подключение, API ключи и приоритеты booru-сервисов'
: 'Configure connections, API keys and search priorities',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF10B981)
.withValues(alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF10B981)
.withValues(alpha: isDark ? 0.40 : 0.28),
),
),
child: Text(
'$activeCount / ${items.length} ${isRu ? 'вкл' : 'on'}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF10B981),
),
),
),
],
),
),
// Empty State
if (items.isEmpty)
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.40)
: Colors.white.withValues(alpha: 0.80),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.10)
: Colors.white,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.cloud_off_rounded,
size: 48,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.5),
),
const SizedBox(height: 12),
Text(
isRu
? 'Нет настроенных источников'
: 'No providers configured',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
isRu
? 'Нажмите «Создать», чтобы добавить новый booru-провайдер'
: 'Tap "Add" to set up a new booru provider',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
)
else
// List of Provider Cards
...items.map((item) {
return ProviderCard(
key: ValueKey(item.id),
config: item,
onToggle: (enabled) => ref
.read(providersControllerProvider.notifier)
.toggle(item.id, enabled),
onEdit: () => context.push(
'/providers/new',
extra: item,
),
onDelete: () async {
final ok = await showConfirmDialog(
context,
title: isRu ? 'Удалить источник' : 'Delete provider',
message: isRu
? 'Вы действительно хотите удалить ${item.name}?'
: 'Remove ${item.name}?',
);
if (ok) {
await ref
.read(providersControllerProvider.notifier)
.delete(item.id);
}
},
);
}),
],
),
),
);
},
), ),
); );
} }
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
@@ -18,27 +19,396 @@ class ProviderCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( final theme = Theme.of(context);
child: ListTile( final isDark = theme.brightness == Brightness.dark;
leading: Switch(value: config.enabled, onChanged: onToggle), final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
title: Text(config.name), final (engineColor, engineIcon, engineTitle) = _engineInfo(config.apiType);
subtitle: Text(
'${config.apiType} · ${config.baseUrl}\nPriority ${config.priority}'), return Container(
isThreeLine: true, margin: const EdgeInsets.only(bottom: 14),
trailing: Wrap( decoration: BoxDecoration(
spacing: 4, borderRadius: BorderRadius.circular(24),
children: [ boxShadow: [
IconButton( BoxShadow(
tooltip: 'Edit', color: isDark
onPressed: onEdit, ? Colors.black.withValues(alpha: 0.30)
icon: const Icon(Icons.edit_rounded), : engineColor.withValues(alpha: 0.08),
blurRadius: 18,
offset: const Offset(0, 6),
spreadRadius: -2,
),
BoxShadow(
color: engineColor.withValues(alpha: isDark ? 0.05 : 0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
theme.colorScheme.surfaceContainerHigh
.withValues(alpha: config.enabled ? 0.65 : 0.35),
theme.colorScheme.surfaceContainerLow
.withValues(alpha: config.enabled ? 0.45 : 0.20),
]
: [
Colors.white
.withValues(alpha: config.enabled ? 0.90 : 0.60),
Colors.white
.withValues(alpha: config.enabled ? 0.75 : 0.45),
],
), ),
IconButton( border: Border.all(
tooltip: 'Delete', color: isDark
onPressed: onDelete, ? (config.enabled
icon: const Icon(Icons.delete_rounded), ? engineColor.withValues(alpha: 0.28)
: Colors.white.withValues(alpha: 0.08))
: (config.enabled
? Colors.white.withValues(alpha: 0.90)
: Colors.white.withValues(alpha: 0.50)),
width: 1.2,
), ),
], ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: Icon squircle + Name & details + Switch
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Engine Icon Squircle Badge
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: config.enabled
? [
engineColor,
engineColor.withValues(alpha: 0.80),
]
: [
Colors.grey.shade600,
Colors.grey.shade700,
],
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
if (config.enabled)
BoxShadow(
color: engineColor.withValues(alpha: 0.38),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Icon(
engineIcon,
size: 22,
color: Colors.white,
),
),
),
const SizedBox(width: 14),
// Name & Sub-details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
config.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: config.enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface
.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 5),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
// Engine badge
_GlassPill(
label: engineTitle,
color: engineColor,
isDark: isDark,
),
// Priority badge
_GlassPill(
label: '${isRu ? 'Приоритет' : 'Priority'} ${config.priority}',
color: theme.colorScheme.primary,
isDark: isDark,
),
],
),
],
),
),
// Switch
Transform.scale(
scale: 0.9,
child: Switch.adaptive(
value: config.enabled,
onChanged: (val) {
HapticFeedback.selectionClick();
onToggle(val);
},
activeThumbColor: engineColor,
activeTrackColor: engineColor.withValues(alpha: 0.45),
),
),
],
),
const SizedBox(height: 12),
// Row 2: Base URL glass chip
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isDark
? Colors.black.withValues(alpha: 0.20)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.40),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.05),
),
),
child: Row(
children: [
Icon(
Icons.link_rounded,
size: 14,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.8),
),
const SizedBox(width: 6),
Expanded(
child: Text(
config.baseUrl,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: -0.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 12),
// Row 3: Action Buttons (Edit & Delete)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Edit Action Button
_GlassActionButton(
icon: Icons.edit_rounded,
label: isRu ? 'Изменить' : 'Edit',
color: theme.colorScheme.primary,
isDark: isDark,
onTap: () {
HapticFeedback.lightImpact();
onEdit();
},
),
const SizedBox(width: 8),
// Delete Action Button
_GlassActionButton(
icon: Icons.delete_outline_rounded,
label: isRu ? 'Удалить' : 'Delete',
color: const Color(0xFFEF4444),
isDark: isDark,
onTap: () {
HapticFeedback.lightImpact();
onDelete();
},
),
],
),
],
),
),
),
);
}
static (Color, IconData, String) _engineInfo(String apiType) {
return switch (apiType.toLowerCase()) {
'gelbooru' => (
const Color(0xFF10B981),
Icons.image_search_rounded,
'Gelbooru',
),
'rule34' => (
const Color(0xFF84CC16),
Icons.photo_library_rounded,
'Rule34',
),
'paheal' => (
const Color(0xFFF97316),
Icons.filter_hdr_rounded,
'Paheal HTML',
),
'realbooru_html' => (
const Color(0xFFEAB308),
Icons.camera_alt_rounded,
'Realbooru HTML',
),
'danbooru' => (
const Color(0xFF3B82F6),
Icons.auto_awesome_rounded,
'Danbooru',
),
'moebooru' => (
const Color(0xFF8B5CF6),
Icons.face_retouching_natural_rounded,
'Moebooru',
),
'e621' => (
const Color(0xFFF59E0B),
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,
apiType.isEmpty ? 'Custom' : apiType,
),
};
}
}
class _GlassPill extends StatelessWidget {
const _GlassPill({
required this.label,
required this.color,
required this.isDark,
});
final String label;
final Color color;
final bool isDark;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
letterSpacing: -0.2,
),
),
);
}
}
class _GlassActionButton extends StatelessWidget {
const _GlassActionButton({
required this.icon,
required this.label,
required this.color,
required this.isDark,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final bool isDark;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.32 : 0.22),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
letterSpacing: -0.1,
),
),
],
),
), ),
), ),
); );
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
import '../../../../shared/widgets/provider_status_badge.dart'; import '../../../../shared/widgets/provider_status_badge.dart';
class ProviderHealthTile extends StatelessWidget { class ProviderHealthTile extends StatefulWidget {
const ProviderHealthTile({ const ProviderHealthTile({
required this.config, required this.config,
required this.health, required this.health,
@@ -17,59 +18,408 @@ class ProviderHealthTile extends StatelessWidget {
final ProviderDiagnostics? diagnostics; final ProviderDiagnostics? diagnostics;
final VoidCallback onCheck; final VoidCallback onCheck;
@override
State<ProviderHealthTile> createState() => _ProviderHealthTileState();
}
class _ProviderHealthTileState extends State<ProviderHealthTile> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final config = widget.config;
final health = widget.health;
final diagnostics = widget.diagnostics;
final hint = ProviderQualityHint.from( final hint = ProviderQualityHint.from(
config: config, config: config,
health: health, health: health,
diagnostics: diagnostics, diagnostics: diagnostics,
isRu: isRu,
); );
return Card(
child: ExpansionTile( final status = health?.status ?? ProviderStatus.unknown;
leading: ProviderStatusBadge(health: health), final statusColor = switch (status) {
title: Text(config.name), ProviderStatus.online => (health != null && health.pingMs > 2500)
subtitle: Wrap( ? const Color(0xFFF59E0B)
spacing: 8, : const Color(0xFF10B981),
runSpacing: 6, ProviderStatus.offline => const Color(0xFFEF4444),
crossAxisAlignment: WrapCrossAlignment.center, ProviderStatus.unknown => const Color(0xFF94A3B8),
children: [ };
Chip(
avatar: Icon(hint.icon, size: 16), final hasError = diagnostics?.lastErrorMessage != null ||
label: Text(hint.label), health?.errorMessage != null;
visualDensity: VisualDensity.compact, final errorMessage = diagnostics?.lastErrorMessage ?? health?.errorMessage;
backgroundColor: hint.color(context).withValues(alpha: 0.16),
side: return Container(
BorderSide(color: hint.color(context).withValues(alpha: 0.4)), margin: const EdgeInsets.only(bottom: 14),
), decoration: BoxDecoration(
Text('${config.apiType} | priority ${config.priority}'), borderRadius: BorderRadius.circular(22),
], boxShadow: [
), BoxShadow(
trailing: IconButton( color: isDark
tooltip: 'Check', ? Colors.black.withValues(alpha: 0.30)
onPressed: onCheck, : statusColor.withValues(alpha: 0.08),
icon: const Icon(Icons.network_check_rounded), blurRadius: 18,
), offset: const Offset(0, 6),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14), spreadRadius: -2,
children: [ ),
Align( BoxShadow(
alignment: Alignment.centerLeft, color: statusColor.withValues(alpha: isDark ? 0.06 : 0.03),
child: Text( blurRadius: 10,
[ offset: const Offset(0, 2),
'${config.enabled ? 'Enabled' : 'Disabled'} | ${config.baseUrl}',
if (health != null) 'Ping ${health!.pingMs} ms',
if (health?.apiVersion != null) 'API ${health!.apiVersion}',
if (health?.lastCheckedAt != null)
'Checked ${health!.lastCheckedAt}',
if (diagnostics != null)
'Last search: ${diagnostics!.lastResultCount} posts at ${diagnostics!.lastSearchAt}',
if (diagnostics?.lastErrorMessage != null)
diagnostics!.lastErrorMessage!,
if (health?.errorMessage != null) health!.errorMessage!,
].join('\n'),
),
), ),
], ],
), ),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.65),
theme.colorScheme.surfaceContainerLow
.withValues(alpha: 0.40),
]
: [
Colors.white.withValues(alpha: 0.92),
Colors.white.withValues(alpha: 0.78),
],
),
border: Border.all(
color: isDark
? statusColor.withValues(alpha: 0.30)
: Colors.white.withValues(alpha: 0.90),
width: 1.2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header Tile
InkWell(
onTap: () {
HapticFeedback.selectionClick();
setState(() => _expanded = !_expanded);
},
borderRadius: BorderRadius.circular(22),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Status Badge with glowing LED
ProviderStatusBadge(health: health),
const SizedBox(width: 12),
// Name & Badges
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Ping chip if available
if (health != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2.5),
decoration: BoxDecoration(
color: statusColor.withValues(
alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: statusColor.withValues(
alpha: isDark ? 0.38 : 0.25),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bolt_rounded,
size: 12,
color: statusColor,
),
const SizedBox(width: 3),
Text(
'${health.pingMs} ms',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: statusColor,
fontFamily: 'monospace',
),
),
],
),
),
// Quality Hint Chip
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2.5),
decoration: BoxDecoration(
color: hint.color(context).withValues(
alpha: isDark ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: hint.color(context).withValues(
alpha: isDark ? 0.32 : 0.20),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hint.icon,
size: 12,
color: hint.color(context),
),
const SizedBox(width: 4),
Text(
hint.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: hint.color(context),
letterSpacing: -0.1,
),
),
],
),
),
],
),
],
),
),
// Check Button
IconButton(
tooltip: isRu ? 'Проверить' : 'Check',
onPressed: () {
HapticFeedback.lightImpact();
widget.onCheck();
},
icon: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: theme.colorScheme.primary
.withValues(alpha: isDark ? 0.18 : 0.10),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary
.withValues(alpha: isDark ? 0.40 : 0.25),
),
),
child: Center(
child: Icon(
Icons.refresh_rounded,
size: 18,
color: theme.colorScheme.primary,
),
),
),
),
// Expand Chevron
Icon(
_expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
// Expanded Diagnostics Section
if (_expanded) ...[
Divider(
height: 1,
thickness: 1,
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Diagnostic row 1: URL & Enabled
_DiagItem(
icon: Icons.link_rounded,
label: isRu ? 'Сервер' : 'Endpoint',
value: '${config.baseUrl} (${config.enabled ? (isRu ? 'Активен' : 'Enabled') : (isRu ? 'Выключен' : 'Disabled')})',
isMonospace: true,
),
const SizedBox(height: 8),
// Diagnostic row 2: API Type & Priority
_DiagItem(
icon: Icons.hub_outlined,
label: isRu ? 'Движок и приоритет' : 'Engine & Priority',
value: '${config.apiType} · ${isRu ? 'приоритет' : 'priority'} ${config.priority}',
),
const SizedBox(height: 8),
// Diagnostic row 3: API Version
if (health?.apiVersion != null) ...[
_DiagItem(
icon: Icons.info_outline_rounded,
label: isRu ? 'Версия API' : 'API Version',
value: health!.apiVersion!,
),
const SizedBox(height: 8),
],
// Diagnostic row 4: Last Checked Time
if (health?.lastCheckedAt != null) ...[
_DiagItem(
icon: Icons.schedule_rounded,
label: isRu ? 'Проверено' : 'Last Checked',
value: health!.lastCheckedAt.toString().split('.').first,
isMonospace: true,
),
const SizedBox(height: 8),
],
// Diagnostic row 5: Last Search
if (diagnostics != null) ...[
_DiagItem(
icon: Icons.search_rounded,
label: isRu ? 'Последний поиск' : 'Last Search',
value: isRu
? '${diagnostics.lastResultCount} постов получено в ${diagnostics.lastSearchAt.toString().split('.').first}'
: '${diagnostics.lastResultCount} posts retrieved at ${diagnostics.lastSearchAt.toString().split('.').first}',
),
const SizedBox(height: 8),
],
// Error Box (if any)
if (hasError && errorMessage != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEF4444)
.withValues(alpha: isDark ? 0.16 : 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: const Color(0xFFEF4444)
.withValues(alpha: isDark ? 0.38 : 0.25),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
size: 16,
color: Color(0xFFEF4444),
),
const SizedBox(width: 8),
Expanded(
child: Text(
errorMessage,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Color(0xFFEF4444),
height: 1.35,
),
),
),
],
),
),
],
],
),
),
],
],
),
),
),
);
}
}
class _DiagItem extends StatelessWidget {
const _DiagItem({
required this.icon,
required this.label,
required this.value,
this.isMonospace = false,
});
final IconData icon;
final String label;
final String value;
final bool isMonospace;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 15,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
const SizedBox(width: 8),
Text(
'$label: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 12,
fontFamily: isMonospace ? 'monospace' : null,
color: theme.colorScheme.onSurface,
),
),
),
],
); );
} }
} }
@@ -87,10 +437,11 @@ class ProviderQualityHint {
required ContentProviderConfig config, required ContentProviderConfig config,
required ProviderHealth? health, required ProviderHealth? health,
required ProviderDiagnostics? diagnostics, required ProviderDiagnostics? diagnostics,
required bool isRu,
}) { }) {
if (!config.enabled) { if (!config.enabled) {
return ProviderQualityHint._( return ProviderQualityHint._(
'Disabled', isRu ? 'Выключен' : 'Disabled',
Icons.pause_circle_outline_rounded, Icons.pause_circle_outline_rounded,
(scheme) => scheme.outline, (scheme) => scheme.outline,
); );
@@ -98,34 +449,34 @@ class ProviderQualityHint {
if (diagnostics?.lastErrorMessage != null || if (diagnostics?.lastErrorMessage != null ||
health?.status == ProviderStatus.offline) { health?.status == ProviderStatus.offline) {
return ProviderQualityHint._( return ProviderQualityHint._(
'Search failing', isRu ? 'Ошибка поиска' : 'Search failing',
Icons.error_outline_rounded, Icons.error_outline_rounded,
(scheme) => scheme.error, (scheme) => scheme.error,
); );
} }
if (diagnostics != null && diagnostics.lastResultCount == 0) { if (diagnostics != null && diagnostics.lastResultCount == 0) {
return ProviderQualityHint._( return ProviderQualityHint._(
'No recent results', isRu ? 'Нет результатов' : 'No recent results',
Icons.search_off_rounded, Icons.search_off_rounded,
(scheme) => scheme.tertiary, (scheme) => scheme.tertiary,
); );
} }
if (health != null && health.pingMs > 2500) { if (health != null && health.pingMs > 2500) {
return ProviderQualityHint._( return ProviderQualityHint._(
'Slow', isRu ? 'Медленно' : 'Slow',
Icons.speed_rounded, Icons.speed_rounded,
(scheme) => scheme.secondary, (scheme) => scheme.secondary,
); );
} }
if (health != null || diagnostics != null) { if (health != null || diagnostics != null) {
return ProviderQualityHint._( return ProviderQualityHint._(
'Healthy', isRu ? 'Стабилен' : 'Healthy',
Icons.check_circle_outline_rounded, Icons.check_circle_outline_rounded,
(scheme) => scheme.primary, (scheme) => scheme.primary,
); );
} }
return ProviderQualityHint._( return ProviderQualityHint._(
'Not checked', isRu ? 'Не проверен' : 'Not checked',
Icons.help_outline_rounded, Icons.help_outline_rounded,
(scheme) => scheme.outline, (scheme) => scheme.outline,
); );
@@ -23,6 +23,16 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidate(appSettingsProvider); ref.invalidate(appSettingsProvider);
} }
Future<void> setVideoPlayerVolume(double volume) async {
final current = state.value ?? AppSettings.defaults;
final clamped = volume.clamp(0.0, 100.0);
if ((current.videoPlayerVolume - clamped).abs() < 0.01) return;
final updated = current.copyWith(videoPlayerVolume: clamped);
state = AsyncData(updated);
await ref.read(settingsServiceProvider).setVideoPlayerVolume(clamped);
ref.invalidate(appSettingsProvider);
}
Future<void> clearCache() async { Future<void> clearCache() async {
await ref.read(cacheServiceProvider).clear(); await ref.read(cacheServiceProvider).clear();
} }
+5 -3
View File
@@ -167,8 +167,10 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _scrollToEnd() { void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_tagScrollController.hasClients) { if (_tagScrollController.hasClients) {
final max = _tagScrollController.position.maxScrollExtent;
final target = (max - 40).clamp(0.0, max);
_tagScrollController.animateTo( _tagScrollController.animateTo(
_tagScrollController.position.maxScrollExtent, target,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
@@ -286,8 +288,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
const SizedBox(width: 6), const SizedBox(width: 6),
], ],
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints( constraints: BoxConstraints(
minWidth: 120, minWidth: _tags.isEmpty ? 120 : 64,
maxWidth: 300, maxWidth: 300,
), ),
child: Focus( child: Focus(
+134 -64
View File
@@ -10,7 +10,7 @@ import '../../app/responsive.dart';
import '../../backend/backend.dart'; import '../../backend/backend.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
class AppShell extends ConsumerWidget { class AppShell extends ConsumerStatefulWidget {
const AppShell({required this.child, super.key}); const AppShell({required this.child, super.key});
final Widget child; final Widget child;
@@ -33,69 +33,6 @@ class AppShell extends ConsumerWidget {
_Destination('settings', 'Settings', Icons.settings_rounded, '/settings'), _Destination('settings', 'Settings', Icons.settings_rounded, '/settings'),
]; ];
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final artistConfigs =
ref.watch(_enabledArtistConfigsProvider).value ?? const [];
final destinations =
_visibleDestinations(settings, artistConfigs.isNotEmpty);
final ru = settings.languageCode == 'ru';
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.digit6):
const _NavigateIntent('/artists'),
LogicalKeySet(LogicalKeyboardKey.digit7):
const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.digit8):
const _NavigateIntent('/settings'),
},
child: Actions(
actions: {
_NavigateIntent: CallbackAction<_NavigateIntent>(
onInvoke: (intent) {
context.go(intent.location);
return null;
},
),
_BackIntent: CallbackAction<_BackIntent>(
onInvoke: (_) {
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Responsive.isDesktop(context)
? _DesktopShell(
destinations: destinations,
ru: ru,
child: child,
)
: _MobileShell(
destinations: destinations,
ru: ru,
child: child,
),
),
),
);
}
static List<_Destination> _visibleDestinations( static List<_Destination> _visibleDestinations(
AppSettings settings, AppSettings settings,
bool hasArtists, bool hasArtists,
@@ -106,6 +43,139 @@ class AppShell extends ConsumerWidget {
return true; return true;
}).toList(growable: false); }).toList(growable: false);
} }
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
final List<String> _tabHistory = [];
String? _currentPath;
bool _isBackNavigating = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final path = GoRouterState.of(context).uri.path;
if (_isBackNavigating) {
_isBackNavigating = false;
_currentPath = path;
return;
}
if (_currentPath != null && _currentPath != path) {
if (path == '/') {
_tabHistory.clear();
} else {
_tabHistory.remove(_currentPath);
_tabHistory.add(_currentPath!);
if (_tabHistory.length > 25) {
_tabHistory.removeAt(0);
}
}
}
_currentPath = path;
}
bool _handleBack() {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
return true;
}
final currentPath = GoRouterState.of(context).uri.path;
while (_tabHistory.isNotEmpty && _tabHistory.last == currentPath) {
_tabHistory.removeLast();
}
if (_tabHistory.isNotEmpty) {
final previous = _tabHistory.removeLast();
_isBackNavigating = true;
context.go(previous);
return true;
}
if (currentPath != '/') {
_isBackNavigating = true;
context.go('/');
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final artistConfigs =
ref.watch(_enabledArtistConfigsProvider).value ?? const [];
final destinations =
AppShell._visibleDestinations(settings, artistConfigs.isNotEmpty);
final ru = settings.languageCode == 'ru';
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
final handled = _handleBack();
if (!handled) {
SystemNavigator.pop();
}
},
child: Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.digit6):
const _NavigateIntent('/artists'),
LogicalKeySet(LogicalKeyboardKey.digit7):
const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.digit8):
const _NavigateIntent('/settings'),
},
child: Actions(
actions: {
_NavigateIntent: CallbackAction<_NavigateIntent>(
onInvoke: (intent) {
context.go(intent.location);
return null;
},
),
_BackIntent: CallbackAction<_BackIntent>(
onInvoke: (_) {
_handleBack();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Responsive.isDesktop(context)
? _DesktopShell(
destinations: destinations,
ru: ru,
child: widget.child,
)
: _MobileShell(
destinations: destinations,
ru: ru,
child: widget.child,
),
),
),
),
);
}
} }
class _DesktopShell extends StatefulWidget { class _DesktopShell extends StatefulWidget {
+2
View File
@@ -596,6 +596,8 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
controller: _controller, controller: _controller,
fit: BoxFit.cover, fit: BoxFit.cover,
controls: null, controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
), ),
), ),
), ),
+78 -9
View File
@@ -3,22 +3,91 @@ import 'package:flutter/material.dart';
import '../../backend/backend.dart'; import '../../backend/backend.dart';
class ProviderStatusBadge extends StatelessWidget { class ProviderStatusBadge extends StatelessWidget {
const ProviderStatusBadge({required this.health, super.key}); const ProviderStatusBadge({
required this.health,
this.compact = false,
super.key,
});
final ProviderHealth? health; final ProviderHealth? health;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final status = health?.status ?? ProviderStatus.unknown; final status = health?.status ?? ProviderStatus.unknown;
final color = switch (status) {
ProviderStatus.online => Colors.green, final (color, labelRu, labelEn) = switch (status) {
ProviderStatus.offline => Colors.red, ProviderStatus.online => (
ProviderStatus.unknown => Colors.grey, const Color(0xFF10B981), // Emerald
'Онлайн',
'Online',
),
ProviderStatus.offline => (
const Color(0xFFEF4444), // Crimson Red
'Офлайн',
'Offline',
),
ProviderStatus.unknown => (
const Color(0xFF94A3B8), // Slate
'Не проверен',
'Unknown',
),
}; };
return Chip(
avatar: Icon(Icons.circle, color: color, size: 10), final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
label: Text(status.name), final label = isRu ? labelRu : labelEn;
visualDensity: VisualDensity.compact,
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 10,
vertical: compact ? 3 : 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.16 : 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.45 : 0.32),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: isDark ? 0.18 : 0.08),
blurRadius: 6,
offset: const Offset(0, 1.5),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.8),
blurRadius: 5,
spreadRadius: 0.5,
),
],
),
),
const SizedBox(width: 6),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
],
),
); );
} }
} }
+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: 3.1.2+63 version: 3.1.8+69
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/backend.dart';
import 'package:gel_rule_app/features/providers/presentation/provider_check_screen.dart';
import 'package:gel_rule_app/features/providers/presentation/provider_form_screen.dart';
import 'package:gel_rule_app/features/providers/presentation/providers_controller.dart';
import 'package:gel_rule_app/features/providers/presentation/providers_screen.dart';
ContentProviderConfig _sampleConfig({
required String id,
required String name,
String apiType = 'gelbooru',
String baseUrl = 'https://gelbooru.com',
bool enabled = true,
int priority = 10,
}) {
return ContentProviderConfig(
id: id,
name: name,
baseUrl: baseUrl,
apiType: apiType,
enabled: enabled,
priority: priority,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 1),
);
}
class _FakeProvidersController extends ProvidersController {
_FakeProvidersController(this._items);
final List<ContentProviderConfig> _items;
@override
Future<List<ContentProviderConfig>> build() async => _items;
}
void main() {
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
testWidgets('ProvidersScreen renders overview card, provider cards, and actions',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final sampleProviders = [
_sampleConfig(id: 'gelbooru', name: 'Gelbooru', apiType: 'gelbooru'),
_sampleConfig(
id: 'rule34',
name: 'Rule34',
apiType: 'rule34',
baseUrl: 'https://api.rule34.xxx'),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
providersControllerProvider.overrideWith(
() => _FakeProvidersController(sampleProviders),
),
],
child: const MaterialApp(
home: ProvidersScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Gelbooru'), findsWidgets);
expect(find.text('Rule34'), findsWidgets);
expect(find.byType(Switch), findsNWidgets(2));
expect(find.byIcon(Icons.network_check_rounded), findsWidgets);
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
});
testWidgets('ProviderFormScreen renders presets, sections, and input fields',
(tester) async {
tester.view.physicalSize = const Size(400, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
const ProviderScope(
child: MaterialApp(
home: ProviderFormScreen(),
),
),
);
await tester.pumpAndSettle();
// Check presets are visible
expect(find.text('Danbooru'), findsOneWidget);
expect(find.text('e621'), findsOneWidget);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
});
testWidgets('ProviderCheckScreen renders metrics and health tiles',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final sampleProviders = [
_sampleConfig(id: 'gelbooru', name: 'Gelbooru', apiType: 'gelbooru'),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
providersControllerProvider.overrideWith(
() => _FakeProvidersController(sampleProviders),
),
providerDiagnosticsProvider.overrideWith(
(ref) async => const {},
),
providerHealthProvider.overrideWith(
(ref) => {
'gelbooru': ProviderHealth(
providerId: 'gelbooru',
status: ProviderStatus.online,
pingMs: 85,
apiVersion: '0.2.5',
lastCheckedAt: DateTime(2026, 1, 1),
),
},
),
],
child: const MaterialApp(
home: ProviderCheckScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Gelbooru'), findsOneWidget);
expect(find.text('85 ms'), findsOneWidget);
});
}