Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a039f09ec3 | ||
|
|
c7f507836b | ||
|
|
1395cfd414 |
@@ -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.6-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.6" /></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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '3.1.6';
|
||||
const appBuildNumber = 67;
|
||||
const appDisplayVersion = '3.1.9';
|
||||
const appBuildNumber = 70;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
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: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
|
||||
|
||||
@@ -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) {
|
||||
@@ -359,13 +362,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
void _initializeVideo() {
|
||||
MediaKit.ensureInitialized();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player!);
|
||||
try {
|
||||
final native = _player!.platform as dynamic;
|
||||
native?.setProperty('hwdec', 'auto-safe', waitForInitialization: false);
|
||||
native?.setProperty('vd-lavc-software-fallback', 'yes', waitForInitialization: false);
|
||||
native?.setProperty('vd-lavc-check-hw-profile', 'yes', waitForInitialization: false);
|
||||
} catch (_) {}
|
||||
_controller = VideoController(
|
||||
_player!,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: !_softwareDecodingFallback,
|
||||
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
|
||||
),
|
||||
);
|
||||
_applyVolume();
|
||||
_player!.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
@@ -437,14 +440,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
lower.contains('stream format not recognized') ||
|
||||
lower.contains('failed to recognize file format'))) {
|
||||
_retriedFormatError = true;
|
||||
try {
|
||||
final native = _player?.platform as dynamic;
|
||||
await native?.setProperty('hwdec', 'no', waitForInitialization: false);
|
||||
} catch (_) {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -456,11 +457,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
Future<void> _retryVideo() async {
|
||||
_videoIndex = 0;
|
||||
_retriedFormatError = false;
|
||||
try {
|
||||
final native = _player?.platform as dynamic;
|
||||
await native?.setProperty('hwdec', 'no', waitForInitialization: false);
|
||||
} catch (_) {}
|
||||
await _openVideo(play: true);
|
||||
_softwareDecodingFallback = true;
|
||||
_disposeVideo();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
if (mounted) {
|
||||
_initializeVideo();
|
||||
}
|
||||
}
|
||||
|
||||
void _disposeVideo() {
|
||||
@@ -570,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,
|
||||
@@ -593,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;
|
||||
}
|
||||
}
|
||||
@@ -728,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,
|
||||
@@ -741,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;
|
||||
@@ -778,6 +787,13 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_applyOrientation();
|
||||
_scheduleControlsHide();
|
||||
if (widget.wasPlaying) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !widget.player.state.playing) {
|
||||
widget.player.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyOrientation() {
|
||||
@@ -803,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();
|
||||
@@ -1035,11 +1054,23 @@ 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.initialVolume;
|
||||
widget.player.setVolume(_currentVolume);
|
||||
_initBrightness();
|
||||
}
|
||||
|
||||
Future<void> _initBrightness() async {
|
||||
try {
|
||||
final b = await ScreenBrightness.instance.application;
|
||||
if (mounted) setState(() => _currentBrightness = b);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1056,6 +1087,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
|
||||
_seekLeftTimer?.cancel();
|
||||
_seekRightTimer?.cancel();
|
||||
_volumeTimer?.cancel();
|
||||
_brightnessTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1127,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) {
|
||||
@@ -1142,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>(
|
||||
@@ -1158,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)
|
||||
@@ -1198,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1545,32 +1602,37 @@ class _RoundControlButton extends StatelessWidget {
|
||||
: Colors.white;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
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),
|
||||
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.45),
|
||||
blurRadius: 28,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(icon, color: foreground, size: iconSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1752,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,
|
||||
@@ -1811,7 +1935,7 @@ class _DoubleTapSeekRipple extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoControls extends StatelessWidget {
|
||||
class _VideoControls extends StatefulWidget {
|
||||
const _VideoControls({
|
||||
required this.player,
|
||||
required this.muted,
|
||||
@@ -1846,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(
|
||||
@@ -1862,7 +1993,7 @@ class _VideoControls extends StatelessWidget {
|
||||
size: 44,
|
||||
iconSize: 24,
|
||||
emphasized: true,
|
||||
onPressed: onToggleLock,
|
||||
onPressed: widget.onToggleLock,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1870,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;
|
||||
|
||||
@@ -1893,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1953,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(
|
||||
@@ -1966,8 +2097,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();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
@@ -1981,20 +2112,22 @@ class _VideoControls extends StatelessWidget {
|
||||
emphasized: true,
|
||||
onPressed: () async {
|
||||
try {
|
||||
if (player.state.completed) {
|
||||
await player.seek(Duration.zero);
|
||||
await player.play();
|
||||
if (widget.player.state.playing) {
|
||||
await widget.player.pause();
|
||||
} else {
|
||||
await player.playOrPause();
|
||||
if (widget.player.state.completed) {
|
||||
await widget.player.seek(Duration.zero);
|
||||
}
|
||||
await widget.player.play();
|
||||
}
|
||||
} catch (_) {
|
||||
if (player.state.playing) {
|
||||
await player.pause();
|
||||
if (widget.player.state.playing) {
|
||||
await widget.player.pause();
|
||||
} else {
|
||||
await player.play();
|
||||
await widget.player.play();
|
||||
}
|
||||
}
|
||||
onInteract?.call();
|
||||
widget.onInteract?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
@@ -2004,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();
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -2024,7 +2157,7 @@ class _VideoControls extends StatelessWidget {
|
||||
14,
|
||||
8,
|
||||
14,
|
||||
fullscreen ? 10 : 4,
|
||||
widget.fullscreen ? 10 : 4,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -2035,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;
|
||||
@@ -2064,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -596,6 +596,8 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
|
||||
controller: _controller,
|
||||
fit: BoxFit.cover,
|
||||
controls: null,
|
||||
pauseUponEnteringBackgroundMode: false,
|
||||
resumeUponEnteringForegroundMode: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+43
-3
@@ -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
@@ -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.6+67
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user