Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1395cfd414 | ||
|
|
15bebd6e47 | ||
|
|
295499ca4b | ||
|
|
9a0fd282b7 |
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user