diff --git a/README.md b/README.md index 5ee50c9..2ec63e0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.1.8 + Version 3.1.9 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 3a5f911..5819934 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.1.8'; -const appBuildNumber = 69; +const appDisplayVersion = '3.1.9'; +const appBuildNumber = 70; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index f8415c0..7d3a0ff 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.1.9', + title: 'Полноэкранный плеер 2.0: мгновенный переход без задержек, жесты яркости и скраббинг с превью', + bullets: [ + 'Мгновенный переход в полноэкранный режим: убраны промежуточные задержки и наложения текстур, вход и выход происходят без малейших задержек.', + 'Двухзонное жестовое управление: левая сторона экрана регулирует яркость подсветки с индикатором солнца, правая сторона — громкость звука.', + 'Плавный скраббинг с плавающим таймингом: перемещение по таймлайну видео теперь отображает интерактивную плашку точного времени, а контролы не исчезают во время удержания ползунка.', + 'Сброс яркости: при выходе из полноэкранного режима системный уровень яркости экрана аккуратно восстанавливается.', + ], + ), LunarisChange( version: '3.1.8', title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления', diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index b534030..48139e3 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -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'; @@ -578,6 +579,8 @@ class _PostMediaViewerState extends State .push( PageRouteBuilder( opaque: true, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, pageBuilder: (_, __, ___) => _FullscreenVideoPage( player: player, controller: controller, @@ -596,10 +599,6 @@ class _PostMediaViewerState extends State unawaited(_applyPlaybackSnapshot(snapshot)); }, ), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), ), ); if (mounted) setState(() => _inFullscreen = false); @@ -820,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(); @@ -1052,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 _initBrightness() async { + try { + final b = await ScreenBrightness.instance.application; + if (mounted) setState(() => _currentBrightness = b); + } catch (_) {} } @override @@ -1073,6 +1087,7 @@ class _VideoSurfaceState extends State<_VideoSurface> { _seekLeftTimer?.cancel(); _seekRightTimer?.cancel(); _volumeTimer?.cancel(); + _brightnessTimer?.cancel(); super.dispose(); } @@ -1144,6 +1159,20 @@ 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; @@ -1182,7 +1211,9 @@ class _VideoSurfaceState extends State<_VideoSurface> { onVerticalDragUpdate: (details) { final x = details.localPosition.dx; final width = MediaQuery.sizeOf(context).width; - if (x > width * 0.4) { + if (x < width * 0.45) { + _adjustBrightness(details.primaryDelta ?? 0.0); + } else if (x > width * 0.55) { _adjustVolume(details.primaryDelta ?? 0.0); } }, @@ -1202,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) @@ -1781,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, @@ -1840,7 +1935,7 @@ class _DoubleTapSeekRipple extends StatelessWidget { } } -class _VideoControls extends StatelessWidget { +class _VideoControls extends StatefulWidget { const _VideoControls({ required this.player, required this.muted, @@ -1875,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( @@ -1891,7 +1993,7 @@ class _VideoControls extends StatelessWidget { size: 44, iconSize: 24, emphasized: true, - onPressed: onToggleLock, + onPressed: widget.onToggleLock, ), ), ), @@ -1899,8 +2001,8 @@ class _VideoControls extends StatelessWidget { } return StreamBuilder( - 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; @@ -1922,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, ), ], ), @@ -1982,8 +2084,8 @@ class _VideoControls extends StatelessWidget { // Center play/pause with quick 10s buttons Center( child: StreamBuilder( - 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( @@ -1995,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), @@ -2010,22 +2112,22 @@ class _VideoControls extends StatelessWidget { emphasized: true, onPressed: () async { try { - if (player.state.playing) { - await player.pause(); + 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(); } } 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), @@ -2035,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(); }, ), ], @@ -2055,7 +2157,7 @@ class _VideoControls extends StatelessWidget { 14, 8, 14, - fullscreen ? 10 : 4, + widget.fullscreen ? 10 : 4, ), decoration: const BoxDecoration( gradient: LinearGradient( @@ -2066,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( - 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( - 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( - 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; @@ -2095,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, + ), + ], ), ], ); diff --git a/pubspec.lock b/pubspec.lock index 3a4095a..4f73c64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 990e03b..b91fd31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.8+69 +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: