Compare commits

...
4 Commits
Author SHA1 Message Date
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
11 changed files with 435 additions and 134 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.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.3" /></a> <a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.7-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.7" /></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.3'; const appDisplayVersion = '3.1.7';
const appBuildNumber = 64; const appBuildNumber = 68;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+39
View File
@@ -1,4 +1,43 @@
const lunarisChangelog = [ const lunarisChangelog = [
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( LunarisChange(
version: '3.1.3', version: '3.1.3',
title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам', title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам',
@@ -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() {
@@ -534,6 +587,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) {
@@ -560,12 +614,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 +633,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 +713,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,6 +722,7 @@ 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 {
@@ -675,6 +734,7 @@ class _FullscreenVideoPage extends StatefulWidget {
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,
@@ -687,6 +747,7 @@ class _FullscreenVideoPage extends StatefulWidget {
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 +762,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,6 +773,8 @@ 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();
@@ -790,6 +854,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 +952,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 +966,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
halfVolume: _halfVolume, halfVolume: _halfVolume,
loopVideo: _loopVideo, loopVideo: _loopVideo,
coverVideo: _coverVideo, coverVideo: _coverVideo,
volume: _currentVolume,
); );
} }
} }
@@ -915,6 +985,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 +1006,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 +1039,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 +1067,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 +1120,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), () {
@@ -1094,6 +1190,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,
), ),
), ),
), ),
@@ -1449,9 +1546,9 @@ class _RoundControlButton extends StatelessWidget {
: Colors.white; : Colors.white;
return Tooltip( return Tooltip(
message: tooltip, message: tooltip,
child: InkResponse( child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed, onTap: onPressed,
radius: size / 2 + 4,
child: AnimatedContainer( child: AnimatedContainer(
duration: AppMotion.duration(context, 140), duration: AppMotion.duration(context, 140),
width: size, width: size,
@@ -1467,12 +1564,15 @@ class _RoundControlButton extends StatelessWidget {
boxShadow: [ boxShadow: [
if (emphasized) if (emphasized)
BoxShadow( BoxShadow(
color: scheme.primary.withValues(alpha: 0.35), color: scheme.primary.withValues(alpha: 0.45),
blurRadius: 14, blurRadius: 28,
spreadRadius: 2,
), ),
], ],
), ),
child: Icon(icon, color: foreground, size: iconSize), child: Center(
child: Icon(icon, color: foreground, size: iconSize),
),
), ),
), ),
); );
@@ -1728,6 +1828,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 +1845,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 +1894,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 +1966,37 @@ 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.completed) {
), await player.seek(Duration.zero);
], await player.play();
), } else {
child: IconButton( await player.playOrPause();
tooltip: playing ? 'Пауза' : 'Воспроизведение', }
iconSize: 42, } catch (_) {
color: scheme.onPrimary, if (player.state.playing) {
onPressed: player.playOrPause, await player.pause();
icon: Icon( } else {
playing await player.play();
? Icons.pause_rounded }
: Icons.play_arrow_rounded, }
), onInteract?.call();
), },
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
_RoundControlButton( _RoundControlButton(
@@ -1896,7 +2004,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 +2021,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 +2036,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 +2086,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 +2095,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 +2118,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,
@@ -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(
+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.3+64 version: 3.1.7+68
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"