Compare commits

...
4 Commits
Author SHA1 Message Date
RarDog a039f09ec3 feat: Fullscreen 2.0 with instant handoff, screen brightness gestures, and real-time scrub preview (v3.1.9)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:43:46 +03:00
RarDog c7f507836b fix: fullscreen playback state retention and responsive control buttons (v3.1.8)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:33:42 +03:00
RarDog 1395cfd414 fix: remove unsafe libmpv setProperty FFI calls causing SIGSEGV crash (v3.1.7)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:17:15 +03:00
RarDog 15bebd6e47 fix: fullscreen play-pause button, search clear providers, video volume persistence & codec fallback (v3.1.6)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 23:09:23 +03:00
8 changed files with 547 additions and 235 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.5" /></a>
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.9-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.9" /></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/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>
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '3.1.5';
const appBuildNumber = 66;
const appDisplayVersion = '3.1.9';
const appBuildNumber = 70;
const appReleaseChannel = 'Stable';
+39
View File
@@ -1,4 +1,43 @@
const lunarisChangelog = [
LunarisChange(
version: '3.1.9',
title: 'Полноэкранный плеер 2.0: мгновенный переход без задержек, жесты яркости и скраббинг с превью',
bullets: [
'Мгновенный переход в полноэкранный режим: убраны промежуточные задержки и наложения текстур, вход и выход происходят без малейших задержек.',
'Двухзонное жестовое управление: левая сторона экрана регулирует яркость подсветки с индикатором солнца, правая сторона — громкость звука.',
'Плавный скраббинг с плавающим таймингом: перемещение по таймлайну видео теперь отображает интерактивную плашку точного времени, а контролы не исчезают во время удержания ползунка.',
'Сброс яркости: при выходе из полноэкранного режима системный уровень яркости экрана аккуратно восстанавливается.',
],
),
LunarisChange(
version: '3.1.8',
title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления',
bullets: [
'Непрерывное воспроизведение при открытии на весь экран: видео больше не останавливается самопроизвольно при входе в полноэкранный режим или смене ориентации устройства.',
'Корректный статус воспроизведения: устранен сброс кнопки на «Пуск» при переходе в полный экран — видео продолжает воспроизведение без ложных остановок.',
'Мгновенный отклик кнопок «Пуск/Пауза» и перемотки: жесты управления поверхностью (двойной тап, смахивание громкости) перемещены в фоновый слой, исключая перехват и отмену нажатий по кнопкам управления.',
'Синхронизация состояния при сворачивании: плеер точно запоминает состояние паузы/воспроизведения при выходе из полноэкранного режима обратно в пост.',
],
),
LunarisChange(
version: '3.1.7',
title: 'Устранение критического сбоя при открытии видеопоста (SIGSEGV в libmpv)',
bullets: [
'Устранение нативного вылета: полностью удалены небезопасные прямые FFI-вызовы setProperty к mpv, приводившие к немедленному падению приложения на Android при инициализации видео.',
'Безопасная конфигурация декодирования: перевод настроек аппаратного ускорения и фоллбэка кодеков на официальный типобезопасный VideoControllerConfiguration (media_kit_video).',
'Надежный фоллбэк при редких ошибках кодека: при несовместимости аппаратного декодера плеер бесшовно переключается на программный FFmpeg-декодер без риска краша.',
],
),
LunarisChange(
version: '3.1.6',
title: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
bullets: [
'Полноэкранный плеер — отзывчивая кнопка Пуск/Пауза: центральная кнопка управления воспроизведением полностью переработана на непроницаемый GestureDetector со сквиркл-откликом, гарантируя мгновенное срабатывание без перехвата внешними жестами.',
'Сохранение источников при нажатии на крестик в поиске: клик по кнопке сброса в строке поиска теперь очищает только поисковый запрос и теги, никогда не сбрасывая выбранные источники до «Все».',
'Безупречное сохранение громкости: громкость видеоплеера корректно инициализируется из настроек, сохраняется при регулировках жестом и переходе между постами, не сбрасываясь на 100% при открытии нового видео и перезапуске приложения.',
'Автоматический fallback для ошибки Could not open codec: видеоплеер получил безопасные профили декодирования и автопереключение на программный лавк-декодер (FFmpeg software fallback) при редких сбоях аппаратного ускорения.',
],
),
LunarisChange(
version: '3.1.5',
title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев',
@@ -104,7 +104,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
},
onCleared: () {
_query = '';
widget.onClearFilters();
widget.onSearch('');
},
),
),
@@ -235,7 +235,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
},
onCleared: () {
_query = '';
widget.onClearFilters();
widget.onSearch('');
},
),
),
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../app/motion.dart';
@@ -75,6 +76,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
late double _currentVolume;
String? _videoError;
bool _retriedFormatError = false;
bool _softwareDecodingFallback = false;
Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription;
@@ -105,6 +107,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0;
_videoError = null;
_retriedFormatError = false;
_softwareDecodingFallback = false;
_imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post);
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
@@ -115,6 +118,27 @@ class _PostMediaViewerState extends State<PostMediaViewer>
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
@@ -177,6 +201,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
onVolumeChanged: (vol) {
_currentVolume = vol;
widget.onVolumeChanged?.call(vol);
_emitPlaybackPreferences();
},
fullscreen: widget.fullscreen,
errorMessage: _videoError,
@@ -337,7 +362,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() {
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player!);
_controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
),
);
_applyVolume();
_player!.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
@@ -381,6 +412,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
play: false,
);
await _applyVolume();
if (initialPosition > Duration.zero) {
await player.seek(initialPosition);
}
@@ -396,18 +428,24 @@ class _PostMediaViewerState extends State<PostMediaViewer>
await _openVideo(play: play);
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 &&
(message.contains('Failed to recognize file format') ||
message.contains('Demuxer error') ||
message.contains('Could not open') ||
message.contains('Stream format not recognized') ||
message.contains('format unrecognized'))) {
(lower.contains('codec') ||
lower.contains('could not open') ||
lower.contains('couldnt open') ||
lower.contains('could not initialize codec') ||
lower.contains('demuxer error') ||
lower.contains('format unrecognized') ||
lower.contains('stream format not recognized') ||
lower.contains('failed to recognize file format'))) {
_retriedFormatError = true;
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted && _player != null) {
_softwareDecodingFallback = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) {
_videoIndex = 0;
await _openVideo(play: play);
_initializeVideo();
return;
}
}
@@ -419,7 +457,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
Future<void> _retryVideo() async {
_videoIndex = 0;
_retriedFormatError = false;
await _openVideo(play: true);
_softwareDecodingFallback = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
_initializeVideo();
}
}
void _disposeVideo() {
@@ -529,15 +572,19 @@ class _PostMediaViewerState extends State<PostMediaViewer>
final player = _player;
final controller = _controller;
if (player == null || controller == null) return;
final wasPlaying = player.state.playing;
setState(() => _inFullscreen = true);
if (!context.mounted) return;
final result = await Navigator.of(context, rootNavigator: true)
.push<VideoPlaybackSnapshot>(
PageRouteBuilder(
opaque: true,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
player: player,
controller: controller,
wasPlaying: wasPlaying,
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
@@ -552,15 +599,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
unawaited(_applyPlaybackSnapshot(snapshot));
},
),
transitionsBuilder: (_, animation, __, child) => FadeTransition(
opacity: animation,
child: child,
),
),
);
if (mounted) setState(() => _inFullscreen = false);
if (result != null) {
await _applyPlaybackSnapshot(result);
if (result.playing && !player.state.playing) {
await player.play();
} else if (!result.playing && player.state.playing) {
await player.pause();
}
return;
}
}
@@ -687,6 +735,7 @@ class _FullscreenVideoPage extends StatefulWidget {
const _FullscreenVideoPage({
required this.player,
required this.controller,
required this.wasPlaying,
required this.aspectRatio,
required this.loopVideo,
required this.muted,
@@ -700,6 +749,7 @@ class _FullscreenVideoPage extends StatefulWidget {
final Player player;
final VideoController controller;
final bool wasPlaying;
final double aspectRatio;
final bool loopVideo;
final bool muted;
@@ -732,10 +782,18 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
_loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume;
_currentVolume = widget.initialVolume;
_applyVolume();
_isLandscape = widget.aspectRatio > 1.05;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_applyOrientation();
_scheduleControlsHide();
if (widget.wasPlaying) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !widget.player.state.playing) {
widget.player.play();
}
});
}
}
void _applyOrientation() {
@@ -761,6 +819,9 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
@override
void dispose() {
_hideTimer?.cancel();
try {
ScreenBrightness.instance.resetApplicationScreenBrightness();
} catch (_) {}
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
super.dispose();
@@ -993,12 +1054,32 @@ class _VideoSurfaceState extends State<_VideoSurface> {
double _currentVolume = 100;
Timer? _volumeTimer;
bool _showBrightnessIndicator = false;
double _currentBrightness = 0.5;
Timer? _brightnessTimer;
@override
void initState() {
super.initState();
_currentVolume = widget.player.state.volume > 0
? widget.player.state.volume
: widget.initialVolume;
_currentVolume = widget.initialVolume;
widget.player.setVolume(_currentVolume);
_initBrightness();
}
Future<void> _initBrightness() async {
try {
final b = await ScreenBrightness.instance.application;
if (mounted) setState(() => _currentBrightness = b);
} catch (_) {}
}
@override
void didUpdateWidget(covariant _VideoSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialVolume != widget.initialVolume) {
_currentVolume = widget.initialVolume;
widget.player.setVolume(_currentVolume);
}
}
@override
@@ -1006,6 +1087,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
_seekLeftTimer?.cancel();
_seekRightTimer?.cancel();
_volumeTimer?.cancel();
_brightnessTimer?.cancel();
super.dispose();
}
@@ -1077,9 +1159,24 @@ class _VideoSurfaceState extends State<_VideoSurface> {
});
}
void _adjustBrightness(double deltaY) async {
if (_isLocked) return;
final newB = (_currentBrightness - deltaY * 0.004).clamp(0.02, 1.0);
_currentBrightness = newB;
try {
await ScreenBrightness.instance.setApplicationScreenBrightness(newB);
} catch (_) {}
setState(() => _showBrightnessIndicator = true);
_brightnessTimer?.cancel();
_brightnessTimer = Timer(const Duration(milliseconds: 1100), () {
if (mounted) setState(() => _showBrightnessIndicator = false);
});
}
@override
Widget build(BuildContext context) {
final player = widget.player;
Offset? lastTapDown;
final child = LayoutBuilder(
builder: (context, constraints) {
@@ -1092,6 +1189,34 @@ class _VideoSurfaceState extends State<_VideoSurface> {
controller: widget.controller,
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => lastTapDown = details.localPosition,
onTap: widget.onInteract,
onDoubleTap: () {
if (lastTapDown != null) {
_onDoubleTapAt(
lastTapDown!,
MediaQuery.sizeOf(context).width,
);
}
},
onLongPressStart: (_) => _startSpeedBoost(),
onLongPressEnd: (_) => _stopSpeedBoost(),
onVerticalDragUpdate: (details) {
final x = details.localPosition.dx;
final width = MediaQuery.sizeOf(context).width;
if (x < width * 0.45) {
_adjustBrightness(details.primaryDelta ?? 0.0);
} else if (x > width * 0.55) {
_adjustVolume(details.primaryDelta ?? 0.0);
}
},
),
),
StreamBuilder<bool>(
@@ -1108,6 +1233,8 @@ class _VideoSurfaceState extends State<_VideoSurface> {
_DoubleTapSeekRipple(isLeft: false, visible: _showRightSeek),
if (_showVolumeIndicator)
_VolumeGestureBadge(volume: _currentVolume),
if (_showBrightnessIndicator)
_BrightnessGestureBadge(brightness: _currentBrightness),
if (_isSpeedBoosted)
const _SpeedBoostOverlayBadge(),
if (widget.errorMessage != null)
@@ -1148,36 +1275,16 @@ class _VideoSurfaceState extends State<_VideoSurface> {
},
);
Offset? lastTapDown;
return MouseRegion(
onHover: (_) => widget.onInteract(),
child: GestureDetector(
onTapDown: (details) => lastTapDown = details.localPosition,
onTap: widget.onInteract,
onDoubleTap: () {
if (lastTapDown != null) {
_onDoubleTapAt(lastTapDown!, MediaQuery.sizeOf(context).width);
}
},
onLongPressStart: (_) => _startSpeedBoost(),
onLongPressEnd: (_) => _stopSpeedBoost(),
onVerticalDragUpdate: (details) {
final x = details.localPosition.dx;
final width = MediaQuery.sizeOf(context).width;
if (x > width * 0.4) {
_adjustVolume(details.primaryDelta ?? 0.0);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
),
);
}
@@ -1495,30 +1602,38 @@ class _RoundControlButton extends StatelessWidget {
: Colors.white;
return Tooltip(
message: tooltip,
child: InkResponse(
onTap: onPressed,
radius: size / 2 + 4,
child: AnimatedContainer(
duration: AppMotion.duration(context, 140),
width: size,
height: size,
decoration: BoxDecoration(
color: background,
shape: BoxShape.circle,
border: Border.all(
color: selected
? scheme.primary.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.14),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkResponse(
containedInkWell: true,
highlightShape: BoxShape.circle,
onTap: onPressed,
child: AnimatedContainer(
duration: AppMotion.duration(context, 140),
width: size,
height: size,
decoration: BoxDecoration(
color: background,
shape: BoxShape.circle,
border: Border.all(
color: selected
? scheme.primary.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.14),
),
boxShadow: [
if (emphasized)
BoxShadow(
color: scheme.primary.withValues(alpha: 0.45),
blurRadius: 28,
spreadRadius: 2,
),
],
),
child: Center(
child: Icon(icon, color: foreground, size: iconSize),
),
boxShadow: [
if (emphasized)
BoxShadow(
color: scheme.primary.withValues(alpha: 0.35),
blurRadius: 14,
),
],
),
child: Icon(icon, color: foreground, size: iconSize),
),
),
);
@@ -1699,6 +1814,68 @@ class _VolumeGestureBadge extends StatelessWidget {
}
}
class _BrightnessGestureBadge extends StatelessWidget {
const _BrightnessGestureBadge({required this.brightness});
final double brightness;
@override
Widget build(BuildContext context) {
final pct = (brightness * 100).round().clamp(0, 100);
final icon = pct < 33
? Icons.brightness_low_rounded
: pct < 66
? Icons.brightness_medium_rounded
: Icons.brightness_high_rounded;
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 24),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: Colors.white,
size: 26,
),
const SizedBox(width: 12),
SizedBox(
width: 96,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: brightness.clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(Colors.white),
),
),
),
const SizedBox(width: 10),
Text(
'$pct%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
],
),
),
);
}
}
class _DoubleTapSeekRipple extends StatelessWidget {
const _DoubleTapSeekRipple({
required this.isLeft,
@@ -1758,7 +1935,7 @@ class _DoubleTapSeekRipple extends StatelessWidget {
}
}
class _VideoControls extends StatelessWidget {
class _VideoControls extends StatefulWidget {
const _VideoControls({
required this.player,
required this.muted,
@@ -1793,11 +1970,18 @@ class _VideoControls extends StatelessWidget {
final void Function(Duration) onSeekBy;
final VoidCallback? onInteract;
@override
State<_VideoControls> createState() => _VideoControlsState();
}
class _VideoControlsState extends State<_VideoControls> {
double? _scrubValue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (isLocked) {
if (widget.isLocked) {
return Align(
alignment: Alignment.topLeft,
child: SafeArea(
@@ -1809,7 +1993,7 @@ class _VideoControls extends StatelessWidget {
size: 44,
iconSize: 24,
emphasized: true,
onPressed: onToggleLock,
onPressed: widget.onToggleLock,
),
),
),
@@ -1817,8 +2001,8 @@ class _VideoControls extends StatelessWidget {
}
return StreamBuilder<double>(
stream: player.stream.rate,
initialData: player.state.rate,
stream: widget.player.stream.rate,
initialData: widget.player.state.rate,
builder: (context, rateSnapshot) {
final currentRate = rateSnapshot.data ?? 1.0;
@@ -1840,56 +2024,56 @@ class _VideoControls extends StatelessWidget {
),
),
child: SafeArea(
top: fullscreen,
top: widget.fullscreen,
bottom: false,
left: fullscreen,
right: fullscreen,
left: widget.fullscreen,
right: widget.fullscreen,
child: Row(
children: [
if (fullscreen) ...[
if (widget.fullscreen) ...[
_RoundControlButton(
tooltip: 'Закрыть',
icon: Icons.arrow_back_rounded,
onPressed: onFullscreen,
onPressed: widget.onFullscreen,
),
const SizedBox(width: 8),
],
_RoundControlButton(
tooltip: 'Заблокировать экран',
icon: Icons.lock_outline_rounded,
onPressed: onToggleLock,
onPressed: widget.onToggleLock,
),
const Spacer(),
_SpeedBadgeButton(
currentRate: currentRate,
onSelected: (newRate) => player.setRate(newRate),
onSelected: (newRate) => widget.player.setRate(newRate),
),
const SizedBox(width: 8),
_RoundControlButton(
tooltip: loopVideo ? 'Выключить повтор' : 'Повтор видео',
selected: loopVideo,
icon: loopVideo
tooltip: widget.loopVideo ? 'Выключить повтор' : 'Повтор видео',
selected: widget.loopVideo,
icon: widget.loopVideo
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
onPressed: onToggleLoop,
onPressed: widget.onToggleLoop,
),
const SizedBox(width: 8),
_RoundControlButton(
tooltip: coverVideo ? 'Вписать' : 'Заполнить',
selected: coverVideo,
icon: coverVideo
tooltip: widget.coverVideo ? 'Вписать' : 'Заполнить',
selected: widget.coverVideo,
icon: widget.coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
onPressed: onToggleFit,
onPressed: widget.onToggleFit,
),
const SizedBox(width: 8),
_RoundControlButton(
tooltip: muted ? 'Включить звук' : 'Выключить звук',
selected: !muted,
icon: muted
tooltip: widget.muted ? 'Включить звук' : 'Выключить звук',
selected: !widget.muted,
icon: widget.muted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
onPressed: onToggleMute,
onPressed: widget.onToggleMute,
),
],
),
@@ -1900,8 +2084,8 @@ class _VideoControls extends StatelessWidget {
// Center play/pause with quick 10s buttons
Center(
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
stream: widget.player.stream.playing,
initialData: widget.player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return Row(
@@ -1913,46 +2097,38 @@ class _VideoControls extends StatelessWidget {
size: 46,
iconSize: 26,
onPressed: () {
onSeekBy(const Duration(seconds: -10));
onInteract?.call();
widget.onSeekBy(const Duration(seconds: -10));
widget.onInteract?.call();
},
),
const SizedBox(width: 32),
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.primary.withValues(alpha: 0.94),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.45),
blurRadius: 28,
spreadRadius: 2,
),
],
),
child: IconButton(
tooltip: playing ? 'Пауза' : 'Воспроизведение',
iconSize: 42,
color: scheme.onPrimary,
onPressed: () async {
if (player.state.playing) {
await player.pause();
_RoundControlButton(
tooltip: playing ? 'Пауза' : 'Воспроизведение',
icon: playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 68,
iconSize: 42,
emphasized: true,
onPressed: () async {
try {
if (widget.player.state.playing) {
await widget.player.pause();
} else {
if (player.state.completed) {
await player.seek(Duration.zero);
if (widget.player.state.completed) {
await widget.player.seek(Duration.zero);
}
await player.play();
await widget.player.play();
}
onInteract?.call();
},
icon: Icon(
playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
),
} catch (_) {
if (widget.player.state.playing) {
await widget.player.pause();
} else {
await widget.player.play();
}
}
widget.onInteract?.call();
},
),
const SizedBox(width: 32),
_RoundControlButton(
@@ -1961,8 +2137,8 @@ class _VideoControls extends StatelessWidget {
size: 46,
iconSize: 26,
onPressed: () {
onSeekBy(const Duration(seconds: 10));
onInteract?.call();
widget.onSeekBy(const Duration(seconds: 10));
widget.onInteract?.call();
},
),
],
@@ -1981,7 +2157,7 @@ class _VideoControls extends StatelessWidget {
14,
8,
14,
fullscreen ? 10 : 4,
widget.fullscreen ? 10 : 4,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -1992,23 +2168,23 @@ class _VideoControls extends StatelessWidget {
),
child: SafeArea(
top: false,
bottom: fullscreen,
left: fullscreen,
right: fullscreen,
bottom: widget.fullscreen,
left: widget.fullscreen,
right: widget.fullscreen,
child: StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
stream: widget.player.stream.duration,
initialData: widget.player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
stream: widget.player.stream.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
final position =
positionSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.buffer,
initialData: player.state.buffer,
stream: widget.player.stream.buffer,
initialData: widget.player.state.buffer,
builder: (context, bufferSnapshot) {
final buffer =
bufferSnapshot.data ?? Duration.zero;
@@ -2021,99 +2197,153 @@ class _VideoControls extends StatelessWidget {
final bufferMs = buffer.inMilliseconds
.clamp(0, maxMs.toInt())
.toDouble();
final displayMs =
(_scrubValue ?? valueMs).clamp(0.0, maxMs);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_format(position),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
if (_scrubValue != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: scheme.primary,
),
),
child: Text(
'${_format(Duration(milliseconds: displayMs.round()))} / ${_format(duration)}',
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// Buffer progress track
if (maxMs > 0 && bufferMs > 0)
Positioned(
left: 14,
right: 14,
child: ClipRRect(
borderRadius:
BorderRadius.circular(2),
child: LinearProgressIndicator(
value: (bufferMs / maxMs)
.clamp(0.0, 1.0),
minHeight: 3.5,
backgroundColor:
Colors.transparent,
valueColor:
AlwaysStoppedAnimation(
Colors.white
.withValues(alpha: 0.28),
Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Text(
_format(Duration(
milliseconds: displayMs.round())),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
),
const SizedBox(width: 4),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// Buffer progress track
if (maxMs > 0 && bufferMs > 0)
Positioned(
left: 14,
right: 14,
child: ClipRRect(
borderRadius:
BorderRadius.circular(2),
child:
LinearProgressIndicator(
value: (bufferMs / maxMs)
.clamp(0.0, 1.0),
minHeight: 3.5,
backgroundColor:
Colors.transparent,
valueColor:
AlwaysStoppedAnimation(
Colors.white.withValues(
alpha: 0.28),
),
),
),
),
),
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: scheme.primary,
inactiveTrackColor: Colors.white
.withValues(alpha: 0.2),
trackHeight: 3.5,
thumbColor: scheme.primary,
overlayColor: scheme.primary
.withValues(alpha: 0.22),
thumbShape:
const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape:
const RoundSliderOverlayShape(
overlayRadius: 14,
),
),
child: Slider(
value: valueMs,
max: maxMs,
onChanged: duration == Duration.zero
? null
: (value) => player.seek(
Duration(
milliseconds:
value.round(),
),
SliderTheme(
data: SliderTheme.of(context)
.copyWith(
activeTrackColor:
scheme.primary,
inactiveTrackColor: Colors.white
.withValues(alpha: 0.2),
trackHeight: 3.5,
thumbColor: scheme.primary,
overlayColor: scheme.primary
.withValues(alpha: 0.22),
thumbShape:
const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape:
const RoundSliderOverlayShape(
overlayRadius: 14,
),
),
child: Slider(
value: displayMs,
max: maxMs,
onChangeStart: (value) {
setState(() =>
_scrubValue = value);
widget.onInteract?.call();
},
onChanged: duration ==
Duration.zero
? null
: (value) {
setState(() =>
_scrubValue =
value);
widget.onInteract
?.call();
},
onChangeEnd: (value) {
widget.player.seek(
Duration(
milliseconds:
value.round(),
),
),
);
setState(
() => _scrubValue = null);
widget.onInteract?.call();
},
),
),
],
),
],
),
),
const SizedBox(width: 4),
Text(
_format(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
const SizedBox(width: 8),
_RoundControlButton(
tooltip: fullscreen
? 'Выйти из полноэкранного режима'
: 'Полноэкранный режим',
icon: fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onPressed: onFullscreen,
),
const SizedBox(width: 4),
Text(
_format(duration),
style: TextStyle(
color: Colors.white
.withValues(alpha: 0.72),
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
const SizedBox(width: 8),
_RoundControlButton(
tooltip: widget.fullscreen
? 'Выйти из полноэкранного режима'
: 'Полноэкранный режим',
icon: widget.fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onPressed: widget.onFullscreen,
),
],
),
],
);
+2
View File
@@ -596,6 +596,8 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
controller: _controller,
fit: BoxFit.cover,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
),
),
+43 -3
View File
@@ -895,11 +895,43 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
screen_brightness:
dependency: "direct main"
description:
name: screen_brightness
sha256: e0edf92c08889e8f493cde291e7c687db2b4a1471f2371c074070b75d7c7d79b
url: "https://pub.dev"
source: hosted
version: "2.1.11"
screen_brightness_android:
dependency: transitive
description:
name: screen_brightness_android
sha256: "2df5e0488a22a73271a43d92dbcd80643df6910cf69690faacaf9929ae9ade3b"
sha256: "2008ad8e9527cc968f7a4de1ec58b476d495b3c612a149dbd6550c4f046da147"
url: "https://pub.dev"
source: hosted
version: "2.1.6"
screen_brightness_ios:
dependency: transitive
description:
name: screen_brightness_ios
sha256: "352d355e8523a186ba1e494b74218e5ca96e51975a0630b8ed89fbb7ff609762"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
screen_brightness_macos:
dependency: transitive
description:
name: screen_brightness_macos
sha256: b0957237b842d846a84363b69f229b339a8f91faced55041e245b2ebff7b0142
url: "https://pub.dev"
source: hosted
version: "2.1.4"
screen_brightness_ohos:
dependency: transitive
description:
name: screen_brightness_ohos
sha256: "569a2c97909a50894b81487619eb7654f5358443a5af05f840c19261f49c0940"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
@@ -907,10 +939,18 @@ packages:
dependency: transitive
description:
name: screen_brightness_platform_interface
sha256: "59d50850d6735d677780fc7359c8e997d0ff6df91c8465161c9e617a7b0a11d8"
sha256: "2de60c0ba569b898950029cc1f7e9dd72bda44a22beb5054aac331cb6fce2ff2"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
screen_brightness_windows:
dependency: transitive
description:
name: screen_brightness_windows
sha256: "368b9c822e1671de81616e48150006e39eff2a434957e47ee638b09a32b2297c"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
share_plus:
dependency: "direct main"
description:
+2 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.1.5+66
version: 3.1.9+70
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -33,6 +33,7 @@ dependencies:
media_kit_libs_android_video: ^1.3.8
share_plus: ^10.0.0
dynamic_color: ^1.7.0
screen_brightness: ^2.1.11
dev_dependencies:
flutter_test: