diff --git a/README.md b/README.md index 4f66745..66eb9b1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.1.4 + Version 3.1.5 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 18a0d70..29348d0 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.1.4'; -const appBuildNumber = 65; +const appDisplayVersion = '3.1.5'; +const appBuildNumber = 66; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 3a7b8b4..96ee15f 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.1.5', + title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев', + bullets: [ + 'Сохранение уровня громкости (Persistent Volume): выбранная громкость видеоплеера теперь сохраняется при свайп-регулировке и переключениях между видео и сохраняется даже после перезапуска приложения.', + 'Исправление кнопок в полноэкранном режиме: кнопки паузы/воспроизведения и быстрой перемотки на ±10 секунд в центре экрана теперь работают мгновенно и надежно с анимацией визуального отклика (Seek Ripple).', + 'Сохранение выбранных источников при очистке тегов: удаление всех тегов в строке поиска больше не сбрасывает выбранные провайдеры до значения «Все», сохраняя ваши активные источники.', + 'Плавная строка поиска: при выборе тега из подсказок поле ввода больше не сдвигается слишком далеко в сторону, оставляя тег и курсор в поле зрения.', + 'Удобный доступ к комментариям в посте: увеличен отступ снизу в деталях поста и расширена зона клика блока комментариев, позволяя комфортно прокрутить их выше системных жестов навигации.', + ], + ), LunarisChange( version: '3.1.4', title: 'Оптимизация видеоплеера — адаптивная высота и идеальное прилегание полосы воспроизведения', diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 7439728..ad8ed69 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -39,6 +39,7 @@ class AppSettings { required this.motionRefreshMode, required this.autoBatterySaver60Hz, required this.videoPlayerMuted, + this.videoPlayerVolume = 100.0, required this.videoPlayerHalfVolume, required this.videoPlayerLoop, required this.videoPlayerCover, @@ -94,6 +95,7 @@ class AppSettings { final String motionRefreshMode; final bool autoBatterySaver60Hz; final bool videoPlayerMuted; + final double videoPlayerVolume; final bool videoPlayerHalfVolume; final bool videoPlayerLoop; final bool videoPlayerCover; @@ -146,6 +148,7 @@ class AppSettings { motionRefreshMode: 'auto', autoBatterySaver60Hz: true, videoPlayerMuted: false, + videoPlayerVolume: 100.0, videoPlayerHalfVolume: false, videoPlayerLoop: false, videoPlayerCover: false, @@ -191,6 +194,7 @@ class AppSettings { String? motionRefreshMode, bool? autoBatterySaver60Hz, bool? videoPlayerMuted, + double? videoPlayerVolume, bool? videoPlayerHalfVolume, bool? videoPlayerLoop, bool? videoPlayerCover, @@ -252,6 +256,7 @@ class AppSettings { motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode, autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz, videoPlayerMuted: videoPlayerMuted ?? this.videoPlayerMuted, + videoPlayerVolume: videoPlayerVolume ?? this.videoPlayerVolume, videoPlayerHalfVolume: videoPlayerHalfVolume ?? this.videoPlayerHalfVolume, videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop, @@ -309,6 +314,7 @@ class AppSettings { 'motionRefreshMode': motionRefreshMode, 'autoBatterySaver60Hz': autoBatterySaver60Hz, 'videoPlayerMuted': videoPlayerMuted, + 'videoPlayerVolume': videoPlayerVolume, 'videoPlayerHalfVolume': videoPlayerHalfVolume, 'videoPlayerLoop': videoPlayerLoop, 'videoPlayerCover': videoPlayerCover, @@ -392,6 +398,8 @@ class AppSettings { defaults.autoBatterySaver60Hz, videoPlayerMuted: (json['videoPlayerMuted'] as bool?) ?? defaults.videoPlayerMuted, + videoPlayerVolume: (json['videoPlayerVolume'] as num?)?.toDouble() ?? + defaults.videoPlayerVolume, videoPlayerHalfVolume: (json['videoPlayerHalfVolume'] as bool?) ?? defaults.videoPlayerHalfVolume, videoPlayerLoop: @@ -601,6 +609,17 @@ class SettingsService { return updateSettings(settings.copyWith(videoPlaybackPositions: positions)); } + Future> setVideoPlayerVolume(double volume) async { + final result = await getSettings(); + if (result is Error) return Error(result.failure); + final settings = (result as Success).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> appendDiagnosticLog(String message) async { final result = await getSettings(); if (result is Error) return Error(result.failure); diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index ee75261..c9363d3 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -374,7 +374,11 @@ class _FeedScreenState extends ConsumerState void _submitSearch(String query) { final trimmed = query.trim(); if (trimmed.isEmpty) { - _clearFilters(); + if (widget.initialQuery != null) { + context.go('/'); + } else { + _applySearchQuery(''); + } return; } final location = '/?q=${Uri.encodeQueryComponent(trimmed)}'; diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index ff0565b..90ff961 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -21,6 +21,7 @@ import '../../../shared/widgets/rating_badge.dart'; import '../../collections/presentation/collection_form_dialog.dart'; import '../../favorites/presentation/favorites_controller.dart'; import '../../feed/presentation/feed_controller.dart'; +import '../../settings/presentation/settings_controller.dart'; import '../../viewed/presentation/viewed_controller.dart'; import 'post_details_controller.dart'; import 'widgets/cloud_mirrors_card.dart'; @@ -249,6 +250,10 @@ class PostDetailsScreen extends ConsumerWidget { 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) => @@ -278,6 +283,10 @@ class PostDetailsScreen extends ConsumerWidget { 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) => @@ -392,6 +401,7 @@ class PostDetailsScreen extends ConsumerWidget { ), const SizedBox(height: 16), _CommentsSection(post: post), + const SizedBox(height: 80), ], ), ), @@ -436,7 +446,12 @@ class PostDetailsScreen extends ConsumerWidget { if ((details.primaryVelocity ?? 0) > 900) _close(context); }, child: ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), + padding: EdgeInsets.fromLTRB( + 8, + 8, + 8, + 120 + MediaQuery.paddingOf(context).bottom, + ), children: [ GestureDetector( behavior: HitTestBehavior.deferToChild, @@ -473,6 +488,10 @@ class PostDetailsScreen extends ConsumerWidget { 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) => @@ -503,6 +522,10 @@ class PostDetailsScreen extends ConsumerWidget { 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) => @@ -609,6 +632,7 @@ class PostDetailsScreen extends ConsumerWidget { ), const SizedBox(height: 12), _CommentsSection(post: post), + const SizedBox(height: 24), ], ), ); @@ -855,7 +879,8 @@ class PostDetailsScreen extends ConsumerWidget { if (settings.videoPlayerMuted == snapshot.muted && settings.videoPlayerHalfVolume == snapshot.halfVolume && settings.videoPlayerLoop == snapshot.loopVideo && - settings.videoPlayerCover == snapshot.coverVideo) { + settings.videoPlayerCover == snapshot.coverVideo && + (settings.videoPlayerVolume - snapshot.volume).abs() < 0.01) { return; } await ref.read(settingsServiceProvider).updateSettings( @@ -864,6 +889,7 @@ class PostDetailsScreen extends ConsumerWidget { videoPlayerHalfVolume: snapshot.halfVolume, videoPlayerLoop: snapshot.loopVideo, videoPlayerCover: snapshot.coverVideo, + videoPlayerVolume: snapshot.volume, ), ); ref.invalidate(appSettingsProvider); @@ -1160,7 +1186,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> { ), clipBehavior: Clip.antiAlias, 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), initiallyExpanded: false, shape: const Border(), diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 6c1ef12..f65f99d 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -27,10 +27,12 @@ class PostMediaViewer extends StatefulWidget { this.initialMuted = false, this.initialCoverVideo = false, this.initialHalfVolume = false, + this.initialVolume = 100.0, this.qualityMode = MediaQualityMode.auto, this.mediaHeaders = const {}, this.onPlaybackSnapshot, this.onPlaybackPreferencesChanged, + this.onVolumeChanged, this.onMediaGestureLockChanged, super.key, }); @@ -44,10 +46,12 @@ class PostMediaViewer extends StatefulWidget { final bool initialMuted; final bool initialCoverVideo; final bool initialHalfVolume; + final double initialVolume; final MediaQualityMode qualityMode; final Map mediaHeaders; final ValueChanged? onPlaybackSnapshot; final ValueChanged? onPlaybackPreferencesChanged; + final ValueChanged? onVolumeChanged; final ValueChanged? onMediaGestureLockChanged; @override @@ -68,6 +72,7 @@ class _PostMediaViewerState extends State late bool _muted; late bool _loopVideo; late bool _halfVolume; + late double _currentVolume; String? _videoError; bool _retriedFormatError = false; Timer? _hideTimer; @@ -83,6 +88,7 @@ class _PostMediaViewerState extends State _muted = widget.initialMuted; _loopVideo = widget.initialLoop; _halfVolume = widget.initialHalfVolume; + _currentVolume = widget.initialVolume; _imageUrls = _buildImageUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post); if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { @@ -167,6 +173,11 @@ class _PostMediaViewerState extends State muted: _muted, loopVideo: _loopVideo, halfVolume: _halfVolume, + initialVolume: _currentVolume, + onVolumeChanged: (vol) { + _currentVolume = vol; + widget.onVolumeChanged?.call(vol); + }, fullscreen: widget.fullscreen, errorMessage: _videoError, onInteract: _showControls, @@ -534,6 +545,7 @@ class _PostMediaViewerState extends State muted: _muted, halfVolume: _halfVolume, coverVideo: _coverVideo, + initialVolume: _currentVolume, errorMessage: _videoError, onRetry: _retryVideo, onChanged: (snapshot) { @@ -560,12 +572,14 @@ class _PostMediaViewerState extends State _loopVideo = snapshot.loopVideo; _coverVideo = snapshot.coverVideo; _halfVolume = snapshot.halfVolume; + _currentVolume = snapshot.volume; }); await _applyVolume(); await _player?.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); _emitPlaybackPreferences(); + widget.onVolumeChanged?.call(_currentVolume); } VideoPlaybackSnapshot _snapshot() { @@ -577,15 +591,16 @@ class _PostMediaViewerState extends State halfVolume: _halfVolume, loopVideo: _loopVideo, coverVideo: _coverVideo, + volume: _currentVolume, ); } Future _applyVolume() async { await _player?.setVolume(_muted - ? 0 + ? 0.0 : _halfVolume - ? 50 - : 100); + ? 50.0 + : _currentVolume); } void _emitPlaybackPreferences() { @@ -656,6 +671,7 @@ class VideoPlaybackSnapshot { required this.halfVolume, required this.loopVideo, required this.coverVideo, + this.volume = 100.0, }); final Duration position; @@ -664,6 +680,7 @@ class VideoPlaybackSnapshot { final bool halfVolume; final bool loopVideo; final bool coverVideo; + final double volume; } class _FullscreenVideoPage extends StatefulWidget { @@ -675,6 +692,7 @@ class _FullscreenVideoPage extends StatefulWidget { required this.muted, required this.halfVolume, required this.coverVideo, + this.initialVolume = 100.0, required this.errorMessage, required this.onRetry, required this.onChanged, @@ -687,6 +705,7 @@ class _FullscreenVideoPage extends StatefulWidget { final bool muted; final bool halfVolume; final bool coverVideo; + final double initialVolume; final String? errorMessage; final VoidCallback onRetry; final ValueChanged onChanged; @@ -701,6 +720,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { late bool _muted; late bool _loopVideo; late bool _halfVolume; + late double _currentVolume; late bool _isLandscape; Timer? _hideTimer; @@ -711,6 +731,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { _muted = widget.muted; _loopVideo = widget.loopVideo; _halfVolume = widget.halfVolume; + _currentVolume = widget.initialVolume; _isLandscape = widget.aspectRatio > 1.05; SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); _applyOrientation(); @@ -790,6 +811,11 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { muted: _muted, loopVideo: _loopVideo, halfVolume: _halfVolume, + initialVolume: _currentVolume, + onVolumeChanged: (vol) { + _currentVolume = vol; + widget.onChanged(_snapshot()); + }, fullscreen: true, errorMessage: widget.errorMessage, onInteract: _showControls, @@ -883,10 +909,10 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { Future _applyVolume() async { await widget.player.setVolume(_muted - ? 0 + ? 0.0 : _halfVolume - ? 50 - : 100); + ? 50.0 + : _currentVolume); } VideoPlaybackSnapshot _snapshot() { @@ -897,6 +923,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { halfVolume: _halfVolume, loopVideo: _loopVideo, coverVideo: _coverVideo, + volume: _currentVolume, ); } } @@ -915,6 +942,8 @@ class _VideoSurface extends StatefulWidget { required this.muted, required this.halfVolume, required this.loopVideo, + this.initialVolume = 100.0, + this.onVolumeChanged, required this.fullscreen, required this.errorMessage, required this.onInteract, @@ -934,6 +963,8 @@ class _VideoSurface extends StatefulWidget { final bool muted; final bool halfVolume; final bool loopVideo; + final double initialVolume; + final ValueChanged? onVolumeChanged; final bool fullscreen; final String? errorMessage; final VoidCallback onInteract; @@ -965,7 +996,9 @@ class _VideoSurfaceState extends State<_VideoSurface> { @override void initState() { super.initState(); - _currentVolume = widget.player.state.volume; + _currentVolume = widget.player.state.volume > 0 + ? widget.player.state.volume + : widget.initialVolume; } @override @@ -983,26 +1016,37 @@ class _VideoSurfaceState extends State<_VideoSurface> { if (target < Duration.zero) target = Duration.zero; if (dur > Duration.zero && target > dur) target = dur; 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) { if (_isLocked) return; if (localPosition.dx < width * 0.4) { _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) { _seekBy(const Duration(seconds: 10)); - setState(() => _showRightSeek = true); - _seekRightTimer?.cancel(); - _seekRightTimer = Timer(const Duration(milliseconds: 650), () { - if (mounted) setState(() => _showRightSeek = false); - }); } 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(); } } @@ -1025,6 +1069,7 @@ class _VideoSurfaceState extends State<_VideoSurface> { final newVol = (_currentVolume - deltaY * 0.5).clamp(0.0, 100.0); _currentVolume = newVol; widget.player.setVolume(newVol); + widget.onVolumeChanged?.call(newVol); setState(() => _showVolumeIndicator = true); _volumeTimer?.cancel(); _volumeTimer = Timer(const Duration(milliseconds: 1100), () { @@ -1094,6 +1139,7 @@ class _VideoSurfaceState extends State<_VideoSurface> { onToggleLoop: widget.onToggleLoop, onFullscreen: widget.onFullscreen, onSeekBy: _seekBy, + onInteract: widget.onInteract, ), ), ), @@ -1728,6 +1774,7 @@ class _VideoControls extends StatelessWidget { required this.onToggleLoop, required this.onFullscreen, required this.onSeekBy, + this.onInteract, }); final Player player; @@ -1744,6 +1791,7 @@ class _VideoControls extends StatelessWidget { final VoidCallback onToggleLoop; final VoidCallback onFullscreen; final void Function(Duration) onSeekBy; + final VoidCallback? onInteract; @override Widget build(BuildContext context) { @@ -1864,7 +1912,10 @@ class _VideoControls extends StatelessWidget { icon: Icons.replay_10_rounded, size: 46, iconSize: 26, - onPressed: () => onSeekBy(const Duration(seconds: -10)), + onPressed: () { + onSeekBy(const Duration(seconds: -10)); + onInteract?.call(); + }, ), const SizedBox(width: 32), Container( @@ -1885,7 +1936,17 @@ class _VideoControls extends StatelessWidget { tooltip: playing ? 'Пауза' : 'Воспроизведение', iconSize: 42, color: scheme.onPrimary, - onPressed: player.playOrPause, + onPressed: () async { + if (player.state.playing) { + await player.pause(); + } else { + if (player.state.completed) { + await player.seek(Duration.zero); + } + await player.play(); + } + onInteract?.call(); + }, icon: Icon( playing ? Icons.pause_rounded @@ -1899,7 +1960,10 @@ class _VideoControls extends StatelessWidget { icon: Icons.forward_10_rounded, size: 46, iconSize: 26, - onPressed: () => onSeekBy(const Duration(seconds: 10)), + onPressed: () { + onSeekBy(const Duration(seconds: 10)); + onInteract?.call(); + }, ), ], ); diff --git a/lib/features/settings/presentation/settings_controller.dart b/lib/features/settings/presentation/settings_controller.dart index 9514318..20f541c 100644 --- a/lib/features/settings/presentation/settings_controller.dart +++ b/lib/features/settings/presentation/settings_controller.dart @@ -23,6 +23,16 @@ class SettingsController extends AsyncNotifier { ref.invalidate(appSettingsProvider); } + Future 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 clearCache() async { await ref.read(cacheServiceProvider).clear(); } diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 1625e00..6b27409 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -167,8 +167,10 @@ class _TagInputSearchBarState extends State { void _scrollToEnd() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_tagScrollController.hasClients) { + final max = _tagScrollController.position.maxScrollExtent; + final target = (max - 40).clamp(0.0, max); _tagScrollController.animateTo( - _tagScrollController.position.maxScrollExtent, + target, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); @@ -286,8 +288,8 @@ class _TagInputSearchBarState extends State { const SizedBox(width: 6), ], ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 120, + constraints: BoxConstraints( + minWidth: _tags.isEmpty ? 120 : 64, maxWidth: 300, ), child: Focus( diff --git a/pubspec.yaml b/pubspec.yaml index 4f2ba92..cf0055c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.1.4+65 +version: 3.1.5+66 environment: sdk: ">=3.3.0 <4.0.0"