From e8448eb748b992c5a1b051ffb1a98925d8ad9ab8 Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 22:49:11 +0300 Subject: [PATCH] feat: mobile video player redesign and format recognition fix (v2.0.11) --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 14 + .../widgets/post_media_viewer.dart | 1055 ++++++++++++----- pubspec.yaml | 2 +- 5 files changed, 788 insertions(+), 289 deletions(-) diff --git a/README.md b/README.md index 04cbf35..be4473e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.10 + Version 2.0.11 Flutter Platforms Gitea diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 96f8c5a..3946d4f 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.10'; -const appBuildNumber = 32; +const appDisplayVersion = '2.0.11'; +const appBuildNumber = 33; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 1a999df..3b6acbf 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,18 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.11', + title: 'Mobile Video Player Redesign & Format Recognition Fix', + bullets: [ + 'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.', + 'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.', + 'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.', + 'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.', + 'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.', + 'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.', + 'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.', + 'Sleek Neon Seekbar with live network buffer progress indicator.', + ], + ), LunarisChange( version: '2.0.10', title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling', diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 8851e79..130dd99 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -65,6 +65,7 @@ class _PostMediaViewerState extends State late bool _loopVideo; late bool _halfVolume; String? _videoError; + bool _retriedFormatError = false; Timer? _hideTimer; StreamSubscription? _errorSubscription; StreamSubscription? _positionSubscription; @@ -93,11 +94,16 @@ class _PostMediaViewerState extends State _imageIndex = 0; _videoIndex = 0; _videoError = null; + _retriedFormatError = false; _imageUrls = _buildImageUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post); if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { _initializeVideo(); } + } else if (oldWidget.mediaHeaders != widget.mediaHeaders) { + if (_videoError != null && _player != null) { + unawaited(_retryVideo()); + } } } @@ -341,6 +347,17 @@ class _PostMediaViewerState extends State await _openVideo(play: play); return; } + // If format error occurred on initial load (often due to async referer headers), retry once automatically + if (!_retriedFormatError && + (message.toLowerCase().contains('format') || + message.toLowerCase().contains('recognize'))) { + _retriedFormatError = true; + await Future.delayed(const Duration(milliseconds: 350)); + if (!mounted) return; + _videoIndex = 0; + await _openVideo(play: play); + return; + } if (!mounted) return; setState(() => _videoError = message); _showControls(); @@ -352,6 +369,7 @@ class _PostMediaViewerState extends State setState(() { _videoError = null; _videoIndex = 0; + _retriedFormatError = false; _controlsVisible = true; }); await player.stop(); @@ -400,9 +418,29 @@ class _PostMediaViewerState extends State } Map _headersFor(Post post) { + String? defaultReferer; + final pid = post.providerId.toLowerCase(); + if (pid.contains('gelbooru')) { + defaultReferer = 'https://gelbooru.com/'; + } else if (pid.contains('rule34')) { + defaultReferer = 'https://rule34.xxx/'; + } else if (pid.contains('realbooru')) { + defaultReferer = 'https://realbooru.com/'; + } else if (pid.contains('danbooru')) { + defaultReferer = 'https://danbooru.donmai.us/'; + } else if (pid.contains('e621') || pid.contains('e926')) { + defaultReferer = 'https://e621.net/'; + } else { + final uri = Uri.tryParse(post.fileUrl); + if (uri != null && uri.hasScheme && uri.host.isNotEmpty) { + defaultReferer = '${uri.scheme}://${uri.host}/'; + } + } + return { 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', 'Accept': '*/*', + if (defaultReferer != null) 'Referer': defaultReferer, ...widget.mediaHeaders, }; } @@ -787,7 +825,7 @@ class _CloseVideoIntent extends Intent { const _CloseVideoIntent(); } -class _VideoSurface extends StatelessWidget { +class _VideoSurface extends StatefulWidget { const _VideoSurface({ required this.player, required this.controller, @@ -826,111 +864,191 @@ class _VideoSurface extends StatelessWidget { final VoidCallback onToggleLoop; final VoidCallback onFullscreen; + @override + State<_VideoSurface> createState() => _VideoSurfaceState(); +} + +class _VideoSurfaceState extends State<_VideoSurface> { + bool _isLocked = false; + bool _showLeftSeek = false; + bool _showRightSeek = false; + Timer? _seekLeftTimer; + Timer? _seekRightTimer; + + bool _isSpeedBoosted = false; + double _savedRate = 1.0; + + bool _showVolumeIndicator = false; + double _currentVolume = 100; + Timer? _volumeTimer; + + @override + void initState() { + super.initState(); + _currentVolume = widget.player.state.volume; + } + + @override + void dispose() { + _seekLeftTimer?.cancel(); + _seekRightTimer?.cancel(); + _volumeTimer?.cancel(); + super.dispose(); + } + + void _seekBy(Duration delta) { + final pos = widget.player.state.position; + final dur = widget.player.state.duration; + var target = pos + delta; + if (target < Duration.zero) target = Duration.zero; + if (dur > Duration.zero && target > dur) target = dur; + widget.player.seek(target); + } + + void _onDoubleTapAt(Offset localPosition, double width) { + if (_isLocked) return; + if (localPosition.dx < width * 0.4) { + _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) { + _seekBy(const Duration(seconds: 10)); + setState(() => _showRightSeek = true); + _seekRightTimer?.cancel(); + _seekRightTimer = Timer(const Duration(milliseconds: 650), () { + if (mounted) setState(() => _showRightSeek = false); + }); + } else { + widget.player.playOrPause(); + widget.onInteract(); + } + } + + void _startSpeedBoost() { + if (_isLocked) return; + _savedRate = widget.player.state.rate; + widget.player.setRate(2.0); + setState(() => _isSpeedBoosted = true); + } + + void _stopSpeedBoost() { + if (!_isSpeedBoosted) return; + widget.player.setRate(_savedRate); + setState(() => _isSpeedBoosted = false); + } + + void _adjustVolume(double deltaY) { + if (_isLocked) return; + final newVol = (_currentVolume - deltaY * 0.5).clamp(0.0, 100.0); + _currentVolume = newVol; + widget.player.setVolume(newVol); + setState(() => _showVolumeIndicator = true); + _volumeTimer?.cancel(); + _volumeTimer = Timer(const Duration(milliseconds: 1100), () { + if (mounted) setState(() => _showVolumeIndicator = false); + }); + } + @override Widget build(BuildContext context) { - final child = Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: Colors.black, - child: Video( - controller: controller, - fit: coverVideo ? BoxFit.cover : BoxFit.contain, - controls: null, - ), - ), - StreamBuilder( - stream: player.stream.buffering, - initialData: player.state.buffering, - builder: (context, snapshot) { - if (snapshot.data != true || errorMessage != null) { - return const SizedBox.shrink(); - } - return const Center(child: CircularProgressIndicator()); - }, - ), - if (errorMessage != null) - VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), - IgnorePointer( - ignoring: !controlsVisible || errorMessage != null, - child: AnimatedOpacity( - opacity: controlsVisible || errorMessage != null ? 1 : 0, - duration: AppMotion.duration(context, 180), - child: _VideoControls( - player: player, - muted: muted, - halfVolume: halfVolume, - loopVideo: loopVideo, - coverVideo: coverVideo, - fullscreen: fullscreen, - onToggleFit: onToggleFit, - onToggleMute: onToggleMute, - onToggleHalfVolume: onToggleHalfVolume, - onToggleLoop: onToggleLoop, - onFullscreen: onFullscreen, + final player = widget.player; + + final child = LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: Colors.black, + child: Video( + controller: widget.controller, + fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain, + controls: null, + ), ), - ), - ), - if (errorMessage == null) - StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, snapshot) { - final playing = snapshot.data ?? false; - return AnimatedScale( - scale: controlsVisible || !playing ? 1 : 0.85, - duration: AppMotion.duration(context, 160), - child: AnimatedOpacity( - opacity: controlsVisible || !playing ? 1 : 0, - duration: AppMotion.duration(context, 160), - child: Center( - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.46), - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Colors.black45, - blurRadius: 24, - offset: Offset(0, 10), - ), - ], - ), - child: IconButton( - tooltip: playing ? 'Pause' : 'Play', - iconSize: fullscreen ? 58 : 44, - color: Colors.white, - onPressed: () { - player.playOrPause(); - onInteract(); - }, - icon: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - ), - ), - ), + StreamBuilder( + stream: player.stream.buffering, + initialData: player.state.buffering, + builder: (context, snapshot) { + if (snapshot.data != true || widget.errorMessage != null) { + return const SizedBox.shrink(); + } + return const Center(child: CircularProgressIndicator()); + }, + ), + _DoubleTapSeekRipple(isLeft: true, visible: _showLeftSeek), + _DoubleTapSeekRipple(isLeft: false, visible: _showRightSeek), + if (_showVolumeIndicator) + _VolumeGestureBadge(volume: _currentVolume), + if (_isSpeedBoosted) + const _SpeedBoostOverlayBadge(), + if (widget.errorMessage != null) + VideoErrorOverlay( + message: widget.errorMessage!, + onRetry: widget.onRetry, + ), + IgnorePointer( + ignoring: (!widget.controlsVisible && !_isLocked) || + widget.errorMessage != null, + child: AnimatedOpacity( + opacity: (widget.controlsVisible || _isLocked) && + widget.errorMessage == null + ? 1 + : 0, + duration: AppMotion.duration(context, 180), + child: _VideoControls( + player: player, + muted: widget.muted, + halfVolume: widget.halfVolume, + loopVideo: widget.loopVideo, + coverVideo: widget.coverVideo, + fullscreen: widget.fullscreen, + isLocked: _isLocked, + onToggleLock: () => setState(() => _isLocked = !_isLocked), + onToggleFit: widget.onToggleFit, + onToggleMute: widget.onToggleMute, + onToggleHalfVolume: widget.onToggleHalfVolume, + onToggleLoop: widget.onToggleLoop, + onFullscreen: widget.onFullscreen, + onSeekBy: _seekBy, ), - ); - }, - ), - ], + ), + ), + ], + ); + }, ); + Offset? lastTapDown; + return MouseRegion( - onHover: (_) => onInteract(), + onHover: (_) => widget.onInteract(), child: GestureDetector( - onTap: onInteract, - onLongPress: player.playOrPause, - onDoubleTap: onFullscreen, + 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(fullscreen ? 0 : 10), - child: fullscreen + borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), + child: widget.fullscreen ? SizedBox.expand(child: child) : AspectRatio( - aspectRatio: aspectRatio.clamp(0.35, 2.4), + aspectRatio: widget.aspectRatio.clamp(0.35, 2.4), child: child, ), ), @@ -1224,6 +1342,8 @@ class _RoundControlButton extends StatelessWidget { required this.onPressed, this.emphasized = false, this.selected = false, + this.size = 36, + this.iconSize = 20, }); final String tooltip; @@ -1231,6 +1351,8 @@ class _RoundControlButton extends StatelessWidget { final VoidCallback onPressed; final bool emphasized; final bool selected; + final double size; + final double iconSize; @override Widget build(BuildContext context) { @@ -1238,8 +1360,8 @@ class _RoundControlButton extends StatelessWidget { final background = emphasized ? scheme.primary : selected - ? scheme.primaryContainer.withValues(alpha: 0.96) - : Colors.white.withValues(alpha: 0.13); + ? scheme.primaryContainer.withValues(alpha: 0.95) + : Colors.black.withValues(alpha: 0.44); final foreground = emphasized ? scheme.onPrimary : selected @@ -1249,17 +1371,261 @@ class _RoundControlButton extends StatelessWidget { message: tooltip, child: InkResponse( onTap: onPressed, - radius: emphasized ? 23 : 21, + radius: size / 2 + 4, child: AnimatedContainer( duration: AppMotion.duration(context, 140), - width: emphasized ? 38 : 34, - height: emphasized ? 38 : 34, + width: size, + height: size, decoration: BoxDecoration( color: background, shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + 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.35), + blurRadius: 14, + ), + ], + ), + child: Icon(icon, color: foreground, size: iconSize), + ), + ), + ); + } +} + +class _SpeedBadgeButton extends StatelessWidget { + const _SpeedBadgeButton({ + required this.currentRate, + required this.onSelected, + }); + + final double currentRate; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return PopupMenuButton( + initialValue: currentRate, + tooltip: 'Скорость воспроизведения', + onSelected: onSelected, + color: const Color(0xFF1E1E24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + itemBuilder: (context) => [ + for (final rate in [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) + PopupMenuItem( + value: rate, + child: Row( + children: [ + if ((rate - currentRate).abs() < 0.05) + Icon(Icons.check_rounded, size: 18, color: scheme.primary) + else + const SizedBox(width: 18), + const SizedBox(width: 8), + Text( + '${rate}x', + style: TextStyle( + color: (rate - currentRate).abs() < 0.05 + ? scheme.primary + : Colors.white, + fontWeight: (rate - currentRate).abs() < 0.05 + ? FontWeight.w900 + : FontWeight.w600, + ), + ), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.44), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.14)), + ), + child: Text( + '${currentRate}x', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + ), + ); + } +} + +class _SpeedBoostOverlayBadge extends StatelessWidget { + const _SpeedBoostOverlayBadge(); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 32, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.7)), + boxShadow: [ + BoxShadow( + color: Colors.amberAccent.withValues(alpha: 0.28), + blurRadius: 18, + spreadRadius: 1, + ), + ], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt_rounded, color: Colors.amberAccent, size: 18), + SizedBox(width: 6), + Text( + '2X УСКОРЕНИЕ', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 12, + letterSpacing: 1.1, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _VolumeGestureBadge extends StatelessWidget { + const _VolumeGestureBadge({required this.volume}); + + final double volume; + + @override + Widget build(BuildContext context) { + final isMuted = volume <= 0.01; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + 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( + isMuted + ? Icons.volume_off_rounded + : volume < 50 + ? Icons.volume_down_rounded + : Icons.volume_up_rounded, + color: Colors.white, + size: 26, + ), + const SizedBox(width: 12), + SizedBox( + width: 96, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (volume / 100.0).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + const SizedBox(width: 10), + Text( + '${volume.round()}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ], + ), + ), + ); + } +} + +class _DoubleTapSeekRipple extends StatelessWidget { + const _DoubleTapSeekRipple({ + required this.isLeft, + required this.visible, + }); + + final bool isLeft; + final bool visible; + + @override + Widget build(BuildContext context) { + if (!visible) return const SizedBox.shrink(); + return Align( + alignment: isLeft ? Alignment.centerLeft : Alignment.centerRight, + child: Container( + width: 140, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: isLeft ? Alignment.centerLeft : Alignment.centerRight, + end: isLeft ? Alignment.centerRight : Alignment.centerLeft, + colors: [ + Colors.white.withValues(alpha: 0.22), + Colors.transparent, + ], + ), + borderRadius: BorderRadius.horizontal( + right: isLeft ? const Radius.circular(90) : Radius.zero, + left: !isLeft ? const Radius.circular(90) : Radius.zero, + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isLeft ? Icons.fast_rewind_rounded : Icons.fast_forward_rounded, + color: Colors.white, + size: 40, + ), + const SizedBox(height: 4), + Text( + isLeft ? '-10 сек' : '+10 сек', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 13, + letterSpacing: 0.5, + shadows: [Shadow(color: Colors.black54, blurRadius: 8)], + ), + ), + ], ), - child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), ), ), ); @@ -1274,11 +1640,14 @@ class _VideoControls extends StatelessWidget { required this.loopVideo, required this.coverVideo, required this.fullscreen, + required this.isLocked, + required this.onToggleLock, required this.onToggleFit, required this.onToggleMute, required this.onToggleHalfVolume, required this.onToggleLoop, required this.onFullscreen, + required this.onSeekBy, }); final Player player; @@ -1287,196 +1656,318 @@ class _VideoControls extends StatelessWidget { final bool loopVideo; final bool coverVideo; final bool fullscreen; + final bool isLocked; + final VoidCallback onToggleLock; final VoidCallback onToggleFit; final VoidCallback onToggleMute; final VoidCallback onToggleHalfVolume; final VoidCallback onToggleLoop; final VoidCallback onFullscreen; + final void Function(Duration) onSeekBy; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return Align( - alignment: Alignment.bottomCenter, - child: SafeArea( - minimum: EdgeInsets.fromLTRB( - fullscreen ? 14 : 8, - 0, - fullscreen ? 14 : 8, - fullscreen ? 10 : 8, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.58), - borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), + + if (isLocked) { + return Align( + alignment: Alignment.topLeft, + child: SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, playingSnapshot) { - final playing = playingSnapshot.data ?? false; - return StreamBuilder( - stream: player.stream.duration, - initialData: player.state.duration, - builder: (context, durationSnapshot) { - final duration = durationSnapshot.data ?? Duration.zero; - return StreamBuilder( - stream: player.stream.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final maxMs = duration.inMilliseconds - .clamp(1, 1 << 31) - .toDouble(); - final valueMs = position.inMilliseconds - .clamp(0, maxMs.toInt()) - .toDouble(); - return LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 560; - final tiny = constraints.maxWidth < 390; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: scheme.primary, - inactiveTrackColor: - Colors.white.withValues(alpha: 0.24), - trackHeight: 3, - thumbColor: scheme.primary, - overlayColor: - scheme.primary.withValues(alpha: 0.18), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - ), - ), - child: Slider( - value: valueMs, - max: maxMs, - onChanged: duration == Duration.zero - ? null - : (value) => player.seek( - Duration( - milliseconds: value.round(), - ), - ), - ), - ), - Row( - children: [ - _RoundControlButton( - tooltip: playing ? 'Pause' : 'Play', - emphasized: true, - onPressed: player.playOrPause, - icon: playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - const SizedBox(width: 8), - Text( - tiny - ? _format(position) - : '${_format(position)} / ${_format(duration)}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - if (!compact) ...[ - _RoundControlButton( - tooltip: 'Back 10s', - onPressed: () => player.seek( - _clampSeek( - position - - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.replay_10_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: 'Forward 10s', - onPressed: () => player.seek( - _clampSeek( - position + - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.forward_10_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: halfVolume - ? 'Normal app volume' - : 'Half app volume', - selected: halfVolume, - onPressed: onToggleHalfVolume, - icon: Icons.volume_down_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: loopVideo - ? 'Disable repeat' - : 'Repeat video', - selected: loopVideo, - onPressed: onToggleLoop, - icon: loopVideo - ? Icons.repeat_one_on_rounded - : Icons.repeat_one_rounded, - ), - const SizedBox(width: 5), - if (!compact) ...[ - _RoundControlButton( - tooltip: coverVideo ? 'Fit' : 'Fill', - selected: coverVideo, - onPressed: onToggleFit, - icon: coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: fullscreen - ? 'Exit fullscreen' - : 'Fullscreen', - onPressed: onFullscreen, - icon: fullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - ), - ], - ), - ], - ); - }, - ); - }, - ); - }, - ); - }, + padding: const EdgeInsets.all(16), + child: _RoundControlButton( + tooltip: 'Разблокировать экран', + icon: Icons.lock_rounded, + size: 44, + iconSize: 24, + emphasized: true, + onPressed: onToggleLock, ), ), ), - ), + ); + } + + return StreamBuilder( + stream: player.stream.rate, + initialData: player.state.rate, + builder: (context, rateSnapshot) { + final currentRate = rateSnapshot.data ?? 1.0; + + return Stack( + fit: StackFit.expand, + children: [ + // Top cinematic gradient with control actions + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: SafeArea( + bottom: false, + child: Row( + children: [ + if (fullscreen) ...[ + _RoundControlButton( + tooltip: 'Закрыть', + icon: Icons.arrow_back_rounded, + onPressed: onFullscreen, + ), + const SizedBox(width: 8), + ], + _RoundControlButton( + tooltip: 'Заблокировать экран', + icon: Icons.lock_outline_rounded, + onPressed: onToggleLock, + ), + const Spacer(), + _SpeedBadgeButton( + currentRate: currentRate, + onSelected: (newRate) => player.setRate(newRate), + ), + const SizedBox(width: 8), + _RoundControlButton( + tooltip: loopVideo ? 'Выключить повтор' : 'Повтор видео', + selected: loopVideo, + icon: loopVideo + ? Icons.repeat_one_on_rounded + : Icons.repeat_one_rounded, + onPressed: onToggleLoop, + ), + const SizedBox(width: 8), + _RoundControlButton( + tooltip: coverVideo ? 'Вписать' : 'Заполнить', + selected: coverVideo, + icon: coverVideo + ? Icons.fit_screen_rounded + : Icons.crop_free_rounded, + onPressed: onToggleFit, + ), + const SizedBox(width: 8), + _RoundControlButton( + tooltip: muted ? 'Включить звук' : 'Выключить звук', + selected: !muted, + icon: muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onPressed: onToggleMute, + ), + ], + ), + ), + ), + ), + + // Center play/pause with quick 10s buttons + Center( + child: StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, playingSnapshot) { + final playing = playingSnapshot.data ?? false; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _RoundControlButton( + tooltip: 'Назад на 10с', + icon: Icons.replay_10_rounded, + size: 46, + iconSize: 26, + onPressed: () => onSeekBy(const Duration(seconds: -10)), + ), + 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: player.playOrPause, + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ), + const SizedBox(width: 32), + _RoundControlButton( + tooltip: 'Вперед на 10с', + icon: Icons.forward_10_rounded, + size: 46, + iconSize: 26, + onPressed: () => onSeekBy(const Duration(seconds: 10)), + ), + ], + ); + }, + ), + ), + + // Bottom cinematic gradient with Seekbar and time + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: SafeArea( + top: false, + child: StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final duration = durationSnapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + final position = + positionSnapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.stream.buffer, + initialData: player.state.buffer, + builder: (context, bufferSnapshot) { + final buffer = + bufferSnapshot.data ?? Duration.zero; + final maxMs = duration.inMilliseconds + .clamp(1, 1 << 31) + .toDouble(); + final valueMs = position.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(); + final bufferMs = buffer.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _format(position), + 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: 20, + right: 20, + 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, + ), + ), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: + value.round(), + ), + ), + ), + ), + ], + ), + ), + 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, + ), + ], + ); + }, + ); + }, + ); + }, + ), + ), + ), + ), + ], + ); + }, ); } @@ -1487,10 +1978,4 @@ class _VideoControls extends StatelessWidget { if (hours > 0) return '$hours:$minutes:$seconds'; return '$minutes:$seconds'; } - - Duration _clampSeek(Duration position, Duration duration) { - if (position < Duration.zero) return Duration.zero; - if (duration > Duration.zero && position > duration) return duration; - return position; - } } diff --git a/pubspec.yaml b/pubspec.yaml index f6cfaed..520e6b7 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: 2.0.10+32 +version: 2.0.11+33 environment: sdk: ">=3.3.0 <4.0.0"