From 39fadc009f19eb662d668b6ef6afcf110a58cf6f Mon Sep 17 00:00:00 2001 From: Flummi Date: Tue, 24 Jun 2025 02:21:06 +0200 Subject: [PATCH] ... --- .gitea/workflows/android.yml | 2 +- lib/controller/settingscontroller.dart | 6 - lib/screens/fullscreen.dart | 90 ++++-- lib/screens/mediadetail.dart | 257 ++++++++++----- lib/screens/mediagrid.dart | 17 +- lib/widgets/video_controls_overlay.dart | 395 ++++++++++++++---------- lib/widgets/video_widget.dart | 92 ++---- 7 files changed, 494 insertions(+), 365 deletions(-) diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 63e91f1..e372f3d 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -41,7 +41,7 @@ jobs: TAR_OPTIONS: --no-same-owner - name: build apk - run: flutter build apk --release --split-per-abi -v + run: flutter build apk --release --split-per-abi - name: release-build uses: akkuman/gitea-release-action@v1 diff --git a/lib/controller/settingscontroller.dart b/lib/controller/settingscontroller.dart index 2af45c4..37d58c7 100644 --- a/lib/controller/settingscontroller.dart +++ b/lib/controller/settingscontroller.dart @@ -19,9 +19,6 @@ class SettingsController extends GetxController { RxBool drawerSwipeEnabled = true.obs; RxInt crossAxisCount = 0.obs; - RxInt videoControlsTimerNotifier = 0.obs; - RxInt hideControlsNotifier = 0.obs; - @override void onInit() { super.onInit(); @@ -53,9 +50,6 @@ class SettingsController extends GetxController { await saveSettings(); } - void resetVideoControlsTimer() => videoControlsTimerNotifier.value++; - void hideVideoControls() => hideControlsNotifier.value++; - Future loadSettings() async { muted.value = await storage.getBoolean(_StorageKeys.muted) ?? false; crossAxisCount.value = diff --git a/lib/screens/fullscreen.dart b/lib/screens/fullscreen.dart index cb64a98..9169f1a 100644 --- a/lib/screens/fullscreen.dart +++ b/lib/screens/fullscreen.dart @@ -8,14 +8,21 @@ import 'package:f0ckapp/widgets/video_widget.dart'; class FullScreenMediaView extends StatefulWidget { final MediaItem item; + final Duration? initialPosition; - const FullScreenMediaView({super.key, required this.item}); + const FullScreenMediaView({ + super.key, + required this.item, + this.initialPosition, + }); @override State createState() => _FullScreenMediaViewState(); } class _FullScreenMediaViewState extends State { + final GlobalKey _videoKey = GlobalKey(); + @override void initState() { super.initState(); @@ -30,44 +37,59 @@ class _FullScreenMediaViewState extends State { super.dispose(); } + void _popWithPosition() { + Duration? currentPosition; + if (widget.item.mime.startsWith('video') && _videoKey.currentState != null) { + currentPosition = _videoKey.currentState!.videoController.value.position; + } + Navigator.of(context).pop(currentPosition); + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: widget.item.mime.startsWith('image') - ? InteractiveViewer( - minScale: 1.0, - maxScale: 7.0, - child: CachedNetworkImage( - imageUrl: widget.item.mediaUrl, - fit: BoxFit.contain, - placeholder: (context, url) => - const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) => - const Icon(Icons.error), + return PopScope( + onPopInvokedWithResult: (bool didPop, Object? result) async { + return _popWithPosition(); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: widget.item.mime.startsWith('image') + ? InteractiveViewer( + minScale: 1.0, + maxScale: 7.0, + child: CachedNetworkImage( + imageUrl: widget.item.mediaUrl, + fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), + errorWidget: (context, url, error) => + const Icon(Icons.error), + ), + ) + : Center( + child: VideoWidget( + key: _videoKey, + details: widget.item, + isActive: true, + fullScreen: true, + initialPosition: widget.initialPosition, + ), ), - ) - : Center( - child: VideoWidget( - details: widget.item, - isActive: true, - fullScreen: true, - ), - ), - ), - SafeArea( - child: Align( - alignment: Alignment.topLeft, - child: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), + ), + SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _popWithPosition, + ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/screens/mediadetail.dart b/lib/screens/mediadetail.dart index 060ad89..261df73 100644 --- a/lib/screens/mediadetail.dart +++ b/lib/screens/mediadetail.dart @@ -39,10 +39,12 @@ class _MediaDetailScreenState extends State { final RxInt _currentIndex = 0.obs; final MethodChannel _mediaSaverChannel = const MethodChannel('MediaShit'); final Map _refreshControllers = {}; + final Map> _videoWidgetKeys = {}; bool _isLoading = true; bool _itemNotFound = false; final Set _readyItemIds = {}; + final Map _showFavoriteAnimation = {}; final List> _shareMenuItems = const [ PopupMenuItem( @@ -140,6 +142,18 @@ class _MediaDetailScreenState extends State { setState(() => _readyItemIds.add(item.id)); } } + + if (idx + 1 < mediaController.items.length) { + DefaultCacheManager().downloadFile( + mediaController.items[idx + 1].mediaUrl, + ); + } + if (idx - 1 >= 0) { + DefaultCacheManager().downloadFile( + mediaController.items[idx - 1].mediaUrl, + ); + } + if (idx >= mediaController.items.length - 2 && !mediaController.loading.value && !mediaController.atEnd.value) { @@ -201,6 +215,37 @@ class _MediaDetailScreenState extends State { } } + Future _handleFullScreen(MediaItem currentItem) async { + if (currentItem.mime.startsWith('image')) { + Get.to( + () => FullScreenMediaView(item: currentItem), + fullscreenDialog: true, + ); + return; + } + + final GlobalKey? key = _videoWidgetKeys[currentItem.id]; + final VideoWidgetState? videoState = key?.currentState; + if (videoState == null || !videoState.videoController.value.isInitialized) { + return; + } + + final Duration position = videoState.videoController.value.position; + await videoState.videoController.pause(); + + final Duration? newPosition = await Get.to( + () => FullScreenMediaView(item: currentItem, initialPosition: position), + fullscreenDialog: true, + ); + + if (mounted && videoState.mounted) { + if (newPosition != null) { + await videoState.videoController.seekTo(newPosition); + } + await videoState.videoController.play(); + } + } + @override void dispose() { _pageController?.dispose(); @@ -210,16 +255,49 @@ class _MediaDetailScreenState extends State { super.dispose(); } + Future _handleFavoriteToggle(MediaItem item, bool isFavorite) async { + if (!authController.isLoggedIn) return; + HapticFeedback.lightImpact(); + + final List? newFavorites = await mediaController.toggleFavorite( + item, + isFavorite, + ); + + final int index = mediaController.items.indexWhere((i) => i.id == item.id); + + if (newFavorites != null && index != -1) { + mediaController.items[index] = item.copyWith(favorites: newFavorites); + mediaController.items.refresh(); + } + + if (!mounted) return; + setState(() => _showFavoriteAnimation[item.id] = true); + Future.delayed(const Duration(milliseconds: 700), () { + if (mounted) { + setState(() => _showFavoriteAnimation[item.id] = false); + } + }); + } + Widget _buildMedia(MediaItem item, bool isActive) { + Widget mediaWidget; if (item.mime.startsWith('image/')) { - return CachedNetworkImage( + mediaWidget = CachedNetworkImage( imageUrl: item.mediaUrl, fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), errorWidget: (c, e, s) => const Icon(Icons.broken_image, size: 100), ); } else if (item.mime.startsWith('video/') || item.mime.startsWith('audio/')) { - return VideoWidget( + final key = _videoWidgetKeys.putIfAbsent( + item.id, + () => GlobalKey(), + ); + mediaWidget = VideoWidget( + key: key, details: item, isActive: isActive, onInitialized: () { @@ -229,8 +307,39 @@ class _MediaDetailScreenState extends State { }, ); } else { - return const Icon(Icons.help_outline, size: 100); + mediaWidget = const Icon(Icons.help_outline, size: 100); } + + final bool isFavorite = + item.favorites?.any((f) => f.userId == authController.user.value?.id) ?? + false; + + return Hero( + tag: 'media_${item.id}', + child: GestureDetector( + onDoubleTap: () => _handleFavoriteToggle(item, isFavorite), + child: Stack( + alignment: Alignment.center, + children: [ + mediaWidget, + AnimatedOpacity( + opacity: _showFavoriteAnimation[item.id] ?? false ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: AnimatedScale( + scale: _showFavoriteAnimation[item.id] ?? false ? 1.0 : 0.5, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutBack, + child: Icon( + isFavorite ? Icons.favorite : Icons.favorite_outline, + color: Colors.red, + size: 100, + ), + ), + ), + ], + ), + ), + ); } @override @@ -267,12 +376,7 @@ class _MediaDetailScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.fullscreen), - onPressed: () { - Get.to( - FullScreenMediaView(item: currentItem), - fullscreenDialog: true, - ); - }, + onPressed: () => _handleFullScreen(currentItem), ), IconButton( icon: const Icon(Icons.download), @@ -291,28 +395,26 @@ class _MediaDetailScreenState extends State { ), ], ), - body: PageView.builder( - controller: _pageController!, - itemCount: mediaController.items.length, - onPageChanged: _onPageChanged, - itemBuilder: (context, index) { - final MediaItem item = mediaController.items[index]; - final bool isReady = _readyItemIds.contains(item.id); - final ScrollController scrollController = ScrollController(); - final PullexRefreshController refreshController = - _refreshControllers.putIfAbsent( - item.id, - () => PullexRefreshController(), - ); + body: Stack( + children: [ + PageView.builder( + controller: _pageController!, + itemCount: mediaController.items.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + final MediaItem item = mediaController.items[index]; + final bool isReady = _readyItemIds.contains(item.id); + final PullexRefreshController refreshController = + _refreshControllers.putIfAbsent( + item.id, + () => PullexRefreshController(), + ); - return Stack( - children: [ - PullexRefresh( + return PullexRefresh( onRefresh: () => _onRefresh(item.id, refreshController), header: const WaterDropHeader(), controller: refreshController, child: CustomScrollView( - controller: scrollController, slivers: [ SliverToBoxAdapter( child: AnimatedBuilder( @@ -335,15 +437,11 @@ class _MediaDetailScreenState extends State { SliverFillRemaining( hasScrollBody: false, fillOverscroll: true, - child: GestureDetector( - onTap: () => settingsController.hideVideoControls(), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [TagSection(tags: item.tags ?? [])], - ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [TagSection(tags: item.tags ?? [])], ), ), ), @@ -352,53 +450,54 @@ class _MediaDetailScreenState extends State { ), ], ), - ), - Obx(() { - if (!authController.isLoggedIn) { - return const SizedBox.shrink(); - } - final MediaItem currentItem = - mediaController.items[_currentIndex.value]; + ); + }, + ), + Obx(() { + if (!authController.isLoggedIn) { + return const SizedBox.shrink(); + } + final MediaItem currentItem = + mediaController.items[_currentIndex.value]; - final bool hasSoftButtons = MediaQuery.of(context).padding.bottom > 24.0; + final bool hasSoftButtons = + MediaQuery.of(context).padding.bottom > 24.0; - return DraggableScrollableSheet( - initialChildSize: hasSoftButtons ? 0.11 : 0.2, - minChildSize: hasSoftButtons ? 0.11 : 0.2, - maxChildSize: hasSoftButtons ? 0.245 : 0.2, - snap: true, - builder: (context, scrollController) => ListView( - controller: scrollController, - padding: const EdgeInsets.only(left: 16, right: 16), - children: [ - FavoriteSection( - item: currentItem, - index: _currentIndex.value, - ), - const SizedBox(height: 16), - Text( - "Dateigröße: ${(currentItem.size / 1024).toStringAsFixed(1)} KB", - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - "Typ: ${currentItem.mime}", - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - "ID: ${currentItem.id}", - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - "Hochgeladen am: ${DateTime.fromMillisecondsSinceEpoch(currentItem.stamp * 1000)}", - style: Theme.of(context).textTheme.bodySmall, - ), - ], + return DraggableScrollableSheet( + initialChildSize: hasSoftButtons ? 0.11 : 0.2, + minChildSize: hasSoftButtons ? 0.11 : 0.2, + maxChildSize: hasSoftButtons ? 0.245 : 0.2, + snap: true, + builder: (context, scrollController) => ListView( + controller: scrollController, + padding: const EdgeInsets.only(left: 16, right: 16), + children: [ + FavoriteSection( + item: currentItem, + index: _currentIndex.value, ), - ); - }), - ], - ); - }, + const SizedBox(height: 16), + Text( + "Dateigröße: ${(currentItem.size / 1024).toStringAsFixed(1)} KB", + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + "Typ: ${currentItem.mime}", + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + "ID: ${currentItem.id}", + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + "Hochgeladen am: ${DateTime.fromMillisecondsSinceEpoch(currentItem.stamp * 1000)}", + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + }), + ], ), persistentFooterButtons: mediaController.tag.value != null ? [TagFooter()] diff --git a/lib/screens/mediagrid.dart b/lib/screens/mediagrid.dart index fff141c..3dba008 100644 --- a/lib/screens/mediagrid.dart +++ b/lib/screens/mediagrid.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pullex/pullex.dart'; +import 'package:f0ckapp/models/item.dart'; import 'package:f0ckapp/widgets/tagfooter.dart'; import 'package:f0ckapp/utils/customsearchdelegate.dart'; import 'package:f0ckapp/widgets/end_drawer.dart'; @@ -177,11 +178,17 @@ class _MediaGridBody extends StatelessWidget { childAspectRatio: 1, ), itemBuilder: (context, index) { - final item = mediaController.items[index]; - return GestureDetector( - key: ValueKey(item.id), - onTap: () => Get.toNamed('/${item.id}'), - child: MediaTile(item: item), + final MediaItem item = mediaController.items[index]; + return Hero( + tag: 'media_${item.id}', + child: Material( + type: MaterialType.transparency, + child: GestureDetector( + key: ValueKey(item.id), + onTap: () => Get.toNamed('/${item.id}'), + child: MediaTile(item: item), + ), + ), ); }, ), diff --git a/lib/widgets/video_controls_overlay.dart b/lib/widgets/video_controls_overlay.dart index 1d395b2..b5b36be 100644 --- a/lib/widgets/video_controls_overlay.dart +++ b/lib/widgets/video_controls_overlay.dart @@ -1,197 +1,111 @@ import 'dart:async'; - import 'package:flutter/material.dart'; - import 'package:cached_video_player_plus/cached_video_player_plus.dart'; +import 'package:get/get.dart'; +import 'package:f0ckapp/controller/settingscontroller.dart'; class VideoControlsOverlay extends StatefulWidget { final CachedVideoPlayerPlusController controller; - final VoidCallback onOverlayTap; - final bool muted; - final VoidCallback onMuteToggle; - const VideoControlsOverlay({ - super.key, - required this.controller, - required this.onOverlayTap, - required this.muted, - required this.onMuteToggle, - }); + const VideoControlsOverlay({super.key, required this.controller}); @override State createState() => _VideoControlsOverlayState(); } class _VideoControlsOverlayState extends State { - bool _showSeekIndicator = false; - bool _isRewinding = false; + final SettingsController _settingsController = Get.find(); Timer? _hideTimer; + bool _controlsVisible = false; + + bool _isScrubbing = false; + Duration _scrubbingStartPosition = Duration.zero; + double _scrubbingStartDx = 0.0; + Duration _scrubbingSeekPosition = Duration.zero; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_listener); + } @override void dispose() { _hideTimer?.cancel(); + widget.controller.removeListener(_listener); super.dispose(); } - void _handleDoubleTap(TapDownDetails details) { - final double screenWidth = MediaQuery.of(context).size.width; - final bool isRewind = details.globalPosition.dx < screenWidth / 2; - widget.onOverlayTap(); - - Future(() { - if (isRewind) { - final Duration newPosition = - widget.controller.value.position - const Duration(seconds: 10); - widget.controller.seekTo( - newPosition < Duration.zero ? Duration.zero : newPosition, - ); - } else { - final Duration newPosition = - widget.controller.value.position + const Duration(seconds: 10); - final Duration duration = widget.controller.value.duration; - widget.controller.seekTo( - newPosition > duration ? duration : newPosition, - ); - } - }); + void _listener() { + if (mounted) { + setState(() {}); + } + } + void _startHideTimer() { _hideTimer?.cancel(); - setState(() { - _showSeekIndicator = true; - _isRewinding = isRewind; - }); - _hideTimer = Timer(const Duration(milliseconds: 500), () { - setState(() => _showSeekIndicator = false); + _hideTimer = Timer(const Duration(seconds: 5), () { + if (mounted) { + setState(() => _controlsVisible = false); + } }); } - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - GestureDetector( - onTap: widget.onOverlayTap, - onDoubleTapDown: _handleDoubleTap, - child: Container(color: Colors.transparent), - ), - AnimatedOpacity( - opacity: _showSeekIndicator ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: Align( - alignment: _isRewinding - ? Alignment.centerLeft - : Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 40.0), - child: Icon( - _isRewinding - ? Icons.fast_rewind_rounded - : Icons.fast_forward_rounded, - color: Colors.white70, - size: 60, - ), - ), - ), - ), - IconButton( - icon: Icon( - widget.controller.value.isPlaying ? Icons.pause : Icons.play_arrow, - size: 64, - ), - onPressed: () { - widget.onOverlayTap(); - widget.controller.value.isPlaying - ? widget.controller.pause() - : widget.controller.play(); - }, - ), - Positioned( - right: 12, - bottom: 12, - child: IconButton( - icon: Icon( - widget.muted ? Icons.volume_off : Icons.volume_up, - size: 16, - ), - onPressed: () { - widget.onOverlayTap(); - widget.onMuteToggle(); - }, - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 0), - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 10, - bottom: 12, - child: Text( - '${_formatDuration(widget.controller.value.position)} / ${_formatDuration(widget.controller.value.duration)}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ), - Listener( - onPointerDown: (_) { - widget.onOverlayTap(); - }, - child: VideoProgressIndicator( - widget.controller, - allowScrubbing: true, - padding: const EdgeInsets.only(top: 25.0), - colors: VideoProgressColors( - playedColor: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.5), - bufferedColor: Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.5), - ), - ), - ), - if (widget.controller.value.duration.inMilliseconds > 0) - Positioned( - left: - (widget.controller.value.position.inMilliseconds / - widget - .controller - .value - .duration - .inMilliseconds) * - constraints.maxWidth - - 6, - bottom: -4, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.primary, - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ], - ); + void _toggleControlsVisibility() { + setState(() => _controlsVisible = !_controlsVisible); + if (_controlsVisible) { + _startHideTimer(); + } + } + + void _handlePlayPause() { + widget.controller.value.isPlaying + ? widget.controller.pause() + : widget.controller.play(); + _startHideTimer(); + } + + void _onHorizontalDragStart(DragStartDetails details) { + if (!widget.controller.value.isInitialized || !_controlsVisible) return; + + setState(() { + _isScrubbing = true; + _scrubbingStartPosition = widget.controller.value.position; + _scrubbingStartDx = details.globalPosition.dx; + _scrubbingSeekPosition = widget.controller.value.position; + }); + _hideTimer?.cancel(); + } + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + if (!_isScrubbing) return; + + final double delta = details.globalPosition.dx - _scrubbingStartDx; + final int seekMillis = + _scrubbingStartPosition.inMilliseconds + (delta * 300).toInt(); + + setState(() { + final Duration duration = widget.controller.value.duration; + final Duration seekDuration = Duration(milliseconds: seekMillis); + final Duration clampedSeekDuration = seekDuration < Duration.zero + ? Duration.zero + : (seekDuration > duration ? duration : seekDuration); + _scrubbingSeekPosition = clampedSeekDuration; + }); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + if (!_isScrubbing) return; + + widget.controller.seekTo(_scrubbingSeekPosition); + setState(() => _isScrubbing = false); + _startHideTimer(); + } + + void _onHorizontalDragCancel() { + if (!_isScrubbing) return; + setState(() => _isScrubbing = false); + _startHideTimer(); } String _formatDuration(Duration? duration) { @@ -199,4 +113,149 @@ class _VideoControlsOverlayState extends State { String twoDigits(int n) => n.toString().padLeft(2, '0'); return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}"; } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleControlsVisibility, + onHorizontalDragStart: _controlsVisible ? _onHorizontalDragStart : null, + onHorizontalDragUpdate: _controlsVisible ? _onHorizontalDragUpdate : null, + onHorizontalDragEnd: _controlsVisible ? _onHorizontalDragEnd : null, + onHorizontalDragCancel: _controlsVisible ? _onHorizontalDragCancel : null, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + color: _controlsVisible && !_isScrubbing + ? Colors.black.withValues(alpha: 0.5) + : Colors.transparent, + ), + AnimatedOpacity( + opacity: _isScrubbing ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: _buildScrubbingIndicator(), + ), + AnimatedOpacity( + opacity: _controlsVisible && !_isScrubbing ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: IgnorePointer( + ignoring: !_controlsVisible, + child: Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: Icon( + widget.controller.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: _handlePlayPause, + ), + Align( + alignment: Alignment.bottomCenter, + child: _buildBottomBar(), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildScrubbingIndicator() { + final Duration positionChange = + _scrubbingSeekPosition - _scrubbingStartPosition; + final String changeSign = positionChange.isNegative ? '-' : '+'; + final String changeText = _formatDuration(positionChange.abs()); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatDuration(_scrubbingSeekPosition), + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + Text( + '[$changeSign$changeText]', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 16, + ), + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + Text( + _formatDuration(widget.controller.value.position), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: VideoProgressIndicator( + widget.controller, + allowScrubbing: true, + colors: VideoProgressColors( + playedColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.white.withValues(alpha: 0.3), + bufferedColor: Colors.white.withValues(alpha: 0.6), + ), + ), + ), + ), + Text( + _formatDuration(widget.controller.value.duration), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Obx( + () => IconButton( + icon: Icon( + _settingsController.muted.value + ? Icons.volume_off + : Icons.volume_up, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + onPressed: () { + _settingsController.toggleMuted(); + _startHideTimer(); + }, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ), + ], + ), + ); + } } diff --git a/lib/widgets/video_widget.dart b/lib/widgets/video_widget.dart index 0650a15..7d12238 100644 --- a/lib/widgets/video_widget.dart +++ b/lib/widgets/video_widget.dart @@ -16,6 +16,7 @@ class VideoWidget extends StatefulWidget { final bool isActive; final bool fullScreen; final VoidCallback? onInitialized; + final Duration? initialPosition; const VideoWidget({ super.key, @@ -23,21 +24,18 @@ class VideoWidget extends StatefulWidget { required this.isActive, this.fullScreen = false, this.onInitialized, + this.initialPosition, }); @override - State createState() => _VideoWidgetState(); + State createState() => VideoWidgetState(); } -class _VideoWidgetState extends State { +class VideoWidgetState extends State { final MediaController mediaController = Get.find(); final SettingsController settingsController = Get.find(); late CachedVideoPlayerPlusController videoController; late Worker _muteWorker; - late Worker _timerResetWorker; - late Worker _hideControlsWorker; - bool _showControls = false; - Timer? _hideControlsTimer; @override void initState() { @@ -48,22 +46,6 @@ class _VideoWidgetState extends State { videoController.setVolume(muted ? 0.0 : 1.0); } }); - _timerResetWorker = ever(settingsController.videoControlsTimerNotifier, ( - _, - ) { - if (widget.isActive && mounted) { - if (!_showControls) { - setState(() => _showControls = true); - } - _startHideControlsTimer(); - } - }); - _hideControlsWorker = ever(settingsController.hideControlsNotifier, (_) { - if (mounted && _showControls) { - setState(() => _showControls = false); - _hideControlsTimer?.cancel(); - } - }); } Future _initController() async { @@ -72,8 +54,14 @@ class _VideoWidgetState extends State { videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); await videoController.initialize(); - widget.onInitialized?.call(); if (!mounted) return; + + setState(() {}); + + if (widget.initialPosition != null) { + await videoController.seekTo(widget.initialPosition!); + } + widget.onInitialized?.call(); videoController.setLooping(true); videoController.setVolume(settingsController.muted.value ? 0.0 : 1.0); @@ -93,10 +81,12 @@ class _VideoWidgetState extends State { } if (widget.isActive != oldWidget.isActive) { - if (widget.isActive) { - videoController.play(); - } else { - videoController.pause(); + if (videoController.value.isInitialized) { + if (widget.isActive) { + videoController.play(); + } else { + videoController.pause(); + } } } } @@ -104,41 +94,12 @@ class _VideoWidgetState extends State { @override void dispose() { _muteWorker.dispose(); - _timerResetWorker.dispose(); - _hideControlsWorker.dispose(); videoController.dispose(); - _hideControlsTimer?.cancel(); super.dispose(); } - void _startHideControlsTimer() { - _hideControlsTimer?.cancel(); - _hideControlsTimer = Timer(const Duration(seconds: 3), () { - if (mounted) { - setState(() => _showControls = false); - } - }); - } - - void _onTap({bool ctrlButton = false}) { - if (ctrlButton) { - _startHideControlsTimer(); - return; - } - - final bool newShowState = !_showControls; - setState(() => _showControls = newShowState); - - if (newShowState) { - _startHideControlsTimer(); - } else { - _hideControlsTimer?.cancel(); - } - } - @override Widget build(BuildContext context) { - final bool muted = settingsController.muted.value; bool isAudio = widget.details.mime.startsWith('audio'); Widget mediaContent; @@ -165,26 +126,13 @@ class _VideoWidgetState extends State { child: Stack( alignment: Alignment.center, children: [ - GestureDetector(onTap: _onTap, child: mediaContent), + mediaContent, AnimatedBuilder( animation: videoController, builder: (context, child) { - if (videoController.value.isInitialized && _showControls) { + if (videoController.value.isInitialized) { return Positioned.fill( - child: GestureDetector( - onTap: _onTap, - child: Container( - color: Colors.black.withValues(alpha: 0.5), - child: VideoControlsOverlay( - controller: videoController, - onOverlayTap: () => _onTap(ctrlButton: true), - muted: muted, - onMuteToggle: () { - settingsController.toggleMuted(); - }, - ), - ), - ), + child: VideoControlsOverlay(controller: videoController), ); } return const SizedBox.shrink();