Compare commits

..
4 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
12 changed files with 360 additions and 119 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.4-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.4" /></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.4'; const appDisplayVersion = '3.1.8';
const appBuildNumber = 65; const appBuildNumber = 69;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+40
View File
@@ -1,4 +1,44 @@
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( LunarisChange(
version: '3.1.4', version: '3.1.4',
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';
@@ -249,6 +250,10 @@ class PostDetailsScreen extends ConsumerWidget {
initialCoverVideo: settings.videoPlayerCover, initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: initialHalfVolume:
settings.videoPlayerHalfVolume, settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) => onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot), _saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) => onPlaybackPreferencesChanged: (snapshot) =>
@@ -278,6 +283,10 @@ class PostDetailsScreen extends ConsumerWidget {
initialCoverVideo: settings.videoPlayerCover, initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: initialHalfVolume:
settings.videoPlayerHalfVolume, settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) => onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot), _saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) => onPlaybackPreferencesChanged: (snapshot) =>
@@ -392,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),
], ],
), ),
), ),
@@ -436,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,
@@ -473,6 +488,10 @@ class PostDetailsScreen extends ConsumerWidget {
initialMuted: settings.videoPlayerMuted, initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover, initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume, initialHalfVolume: settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) => onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot), _saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) => onPlaybackPreferencesChanged: (snapshot) =>
@@ -503,6 +522,10 @@ class PostDetailsScreen extends ConsumerWidget {
initialMuted: settings.videoPlayerMuted, initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover, initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume, initialHalfVolume: settings.videoPlayerHalfVolume,
initialVolume: settings.videoPlayerVolume,
onVolumeChanged: (vol) => ref
.read(settingsControllerProvider.notifier)
.setVideoPlayerVolume(vol),
onPlaybackSnapshot: (snapshot) => onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot), _saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) => onPlaybackPreferencesChanged: (snapshot) =>
@@ -609,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),
], ],
), ),
); );
@@ -855,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(
@@ -864,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);
@@ -1160,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) {
@@ -1864,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(
@@ -1899,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();
},
), ),
], ],
); );
@@ -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(
+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,
), ),
), ),
), ),
+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.4+65 version: 3.1.8+69
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"