diff --git a/lib/screens/mediadetail.dart b/lib/screens/mediadetail.dart index 09d4abd..9a53a3b 100644 --- a/lib/screens/mediadetail.dart +++ b/lib/screens/mediadetail.dart @@ -7,6 +7,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:get/get.dart'; import 'package:pullex/pullex.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:timeago/timeago.dart' as timeago; import 'package:f0ckapp/services/api.dart'; import 'package:f0ckapp/widgets/tagfooter.dart'; @@ -43,8 +44,8 @@ class _MediaDetailScreenState extends State { bool _isLoading = true; bool _itemNotFound = false; - final Set _readyItemIds = {}; - final Map _showFavoriteAnimation = {}; + final RxSet _readyItemIds = {}.obs; + final Rxn _animatingFavoriteId = Rxn(); final List> _shareMenuItems = const [ PopupMenuItem( @@ -67,6 +68,7 @@ class _MediaDetailScreenState extends State { @override void initState() { super.initState(); + timeago.setLocaleMessages('de', timeago.DeMessages()); _loadInitialItem(); } @@ -139,7 +141,7 @@ class _MediaDetailScreenState extends State { _currentIndex.value = idx; final MediaItem item = mediaController.items[idx]; if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) { - setState(() => _readyItemIds.add(item.id)); + _readyItemIds.add(item.id); } } @@ -271,11 +273,10 @@ class _MediaDetailScreenState extends State { mediaController.items.refresh(); } - if (!mounted) return; - setState(() => _showFavoriteAnimation[item.id] = true); + _animatingFavoriteId.value = item.id; Future.delayed(const Duration(milliseconds: 700), () { - if (mounted) { - setState(() => _showFavoriteAnimation[item.id] = false); + if (_animatingFavoriteId.value == item.id) { + _animatingFavoriteId.value = null; } }); } @@ -284,18 +285,15 @@ class _MediaDetailScreenState extends State { Widget mediaWidget; final bool isFavorite = item.favorites?.any((f) => f.userId == authController.user.value?.id) ?? - false; + false; if (item.mime.startsWith('image/')) { - mediaWidget = GestureDetector( - onDoubleTap: () => _handleFavoriteToggle(item, isFavorite), - child: 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), - ), + 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/')) { @@ -309,10 +307,9 @@ class _MediaDetailScreenState extends State { isActive: isActive, onInitialized: () { if (mounted && !_readyItemIds.contains(item.id)) { - setState(() => _readyItemIds.add(item.id)); + _readyItemIds.add(item.id); } }, - onDoubleTap: () => _handleFavoriteToggle(item, isFavorite), ); } else { mediaWidget = const Icon(Icons.help_outline, size: 100); @@ -323,187 +320,207 @@ class _MediaDetailScreenState extends State { 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, - ), - ), + GestureDetector( + onDoubleTap: () => _handleFavoriteToggle(item, isFavorite), + child: mediaWidget, ), + Obx(() { + final showAnimation = _animatingFavoriteId.value == item.id; + return AnimatedOpacity( + opacity: showAnimation ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: AnimatedScale( + scale: showAnimation ? 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 - Widget build(BuildContext context) { + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + title: Obx(() { + if (_isLoading) { + return Text('Lade f0ck #${widget.initialId}...'); + } + if (_itemNotFound || + mediaController.items.isEmpty || + _currentIndex.value >= mediaController.items.length) { + return const Text('Fehler'); + } + final MediaItem currentItem = + mediaController.items[_currentIndex.value]; + return Text('f0ck #${currentItem.id}'); + }), + actions: [ + Obx(() { + final bool showActions = + !_isLoading && + !_itemNotFound && + mediaController.items.isNotEmpty && + _currentIndex.value < mediaController.items.length; + + if (!showActions) { + return const SizedBox.shrink(); + } + final MediaItem currentItem = + mediaController.items[_currentIndex.value]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.fullscreen), + onPressed: () => _handleFullScreen(currentItem), + ), + IconButton( + icon: const Icon(Icons.download), + onPressed: () async => await _downloadMedia(currentItem), + ), + PopupMenuButton( + onSelected: (value) => _handleShareAction(value, currentItem), + itemBuilder: (context) => _shareMenuItems, + icon: const Icon(Icons.share), + ), + ], + ); + }), + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openEndDrawer(), + ), + ), + ], + ); + } + + Widget _buildBody(BuildContext context) { if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text('Lade f0ck #${widget.initialId}...')), - body: const Center(child: CircularProgressIndicator()), - ); + return const Center(child: CircularProgressIndicator()); } if (_itemNotFound) { - return Scaffold( - appBar: AppBar(title: const Text('Fehler')), - body: const Center(child: Text('f0ck nicht gefunden.')), - ); + return const Center(child: Text('f0ck nicht gefunden.')); } return Obx(() { - if (mediaController.items.isEmpty || - _currentIndex.value >= mediaController.items.length) { - return Scaffold( - appBar: AppBar(title: const Text('Fehler')), - body: const Center(child: Text('Keine Items zum Anzeigen.')), - ); + if (mediaController.items.isEmpty) { + return const Center(child: Text('Keine Items zum Anzeigen.')); } - final MediaItem currentItem = mediaController.items[_currentIndex.value]; - return Scaffold( - endDrawer: const EndDrawer(), - endDrawerEnableOpenDragGesture: - settingsController.drawerSwipeEnabled.value, - appBar: AppBar( - title: Text('f0ck #${currentItem.id}'), - actions: [ - IconButton( - icon: const Icon(Icons.fullscreen), - onPressed: () => _handleFullScreen(currentItem), - ), - IconButton( - icon: const Icon(Icons.download), - onPressed: () async => await _downloadMedia(currentItem), - ), - PopupMenuButton( - onSelected: (value) => _handleShareAction(value, currentItem), - itemBuilder: (context) => _shareMenuItems, - icon: const Icon(Icons.share), - ), - Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(context).openEndDrawer(), - ), - ), - ], - ), - 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 PullexRefresh( - onRefresh: () => _onRefresh(item.id, refreshController), - header: const WaterDropHeader(), - controller: refreshController, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: AnimatedBuilder( - animation: _pageController!, - builder: (context, child) { - return buildAnimatedTransition( - context: context, - pageController: _pageController!, - index: index, - child: child!, - ); - }, - child: Obx( - () => - _buildMedia(item, index == _currentIndex.value), - ), - ), + return PageView.builder( + controller: _pageController!, + itemCount: mediaController.items.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + if (index >= mediaController.items.length) { + return const SizedBox.shrink(); + } + final MediaItem item = mediaController.items[index]; + final PullexRefreshController refreshController = _refreshControllers + .putIfAbsent(item.id, () => PullexRefreshController()); + + return Obx(() { + final bool isReady = _readyItemIds.contains(item.id); + return PullexRefresh( + onRefresh: () => _onRefresh(item.id, refreshController), + header: const WaterDropHeader(), + controller: refreshController, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: AnimatedBuilder( + animation: _pageController!, + builder: (context, child) { + return buildAnimatedTransition( + context: context, + pageController: _pageController!, + index: index, + child: child!, + ); + }, + child: Obx( + () => _buildMedia(item, index == _currentIndex.value), ), - if (isReady) - SliverFillRemaining( - hasScrollBody: false, - fillOverscroll: true, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [TagSection(tags: item.tags ?? [])], - ), - ), - ), - const SliverToBoxAdapter( - child: SafeArea(child: SizedBox.shrink()), - ), - ], + ), ), - ); - }, - ), - 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; - - 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, + if (isReady) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TagSection(tags: item.tags ?? []), + Obx(() { + if (!authController.isLoggedIn) { + return const SizedBox.shrink(); + } + final TextStyle? infoTextStyle = Theme.of( + context, + ).textTheme.bodySmall; + return Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + children: [ + FavoriteSection(item: item, index: index), + const SizedBox(height: 16), + Text( + "Dateigröße: ${(item.size / 1024).toStringAsFixed(1)} KB", + style: infoTextStyle, + ), + Text( + "Typ: ${item.mime}", + style: infoTextStyle, + ), + Text( + "ID: ${item.id}", + style: infoTextStyle, + ), + Text( + "Hochgeladen: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(item.stamp * 1000), locale: 'de')}", + style: infoTextStyle, + ), + ], + ), + ); + }), + ], + ), + ), ), - 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()] - : null, + const SliverToBoxAdapter( + child: SafeArea(child: SizedBox.shrink()), + ), + ], + ), + ); + }); + }, ); }); } + + @override + Widget build(BuildContext context) { + return Scaffold( + endDrawer: const EndDrawer(), + endDrawerEnableOpenDragGesture: + settingsController.drawerSwipeEnabled.value, + appBar: _buildAppBar(context), + body: _buildBody(context), + persistentFooterButtons: mediaController.tag.value != null + ? [TagFooter()] + : null, + ); + } } diff --git a/lib/widgets/video_controls_overlay.dart b/lib/widgets/video_controls_overlay.dart index f9a4644..d7ef2cc 100644 --- a/lib/widgets/video_controls_overlay.dart +++ b/lib/widgets/video_controls_overlay.dart @@ -1,17 +1,18 @@ 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? onDoubleTap; const VideoControlsOverlay({ super.key, required this.controller, - this.onDoubleTap, }); @override @@ -121,37 +122,37 @@ class _VideoControlsOverlayState extends State { @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: Align( - alignment: Alignment.bottomCenter, - child: _buildBottomBar(), + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _toggleControlsVisibility, + onHorizontalDragStart: _controlsVisible ? _onHorizontalDragStart : null, + onHorizontalDragUpdate: _controlsVisible ? _onHorizontalDragUpdate : null, + onHorizontalDragEnd: _controlsVisible ? _onHorizontalDragEnd : null, + onHorizontalDragCancel: _controlsVisible ? _onHorizontalDragCancel : null, + child: 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: Align( + alignment: Alignment.bottomCenter, + child: _buildBottomBar(), + ), + ), + ], ); } @@ -192,7 +193,7 @@ class _VideoControlsOverlayState extends State { Widget _buildBottomBar() { return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.fromLTRB(12, 0, 12, 5), child: Row( children: [ IconButton( @@ -201,7 +202,7 @@ class _VideoControlsOverlayState extends State { ? Icons.pause : Icons.play_arrow, color: Theme.of(context).colorScheme.primary, - size: 20, + size: 24, ), onPressed: _handlePlayPause, constraints: const BoxConstraints(), @@ -243,7 +244,7 @@ class _VideoControlsOverlayState extends State { ? Icons.volume_off : Icons.volume_up, color: Theme.of(context).colorScheme.primary, - size: 20, + size: 24, ), onPressed: () { _settingsController.toggleMuted(); diff --git a/lib/widgets/video_widget.dart b/lib/widgets/video_widget.dart index 1e8ad27..30619c7 100644 --- a/lib/widgets/video_widget.dart +++ b/lib/widgets/video_widget.dart @@ -17,7 +17,6 @@ class VideoWidget extends StatefulWidget { final bool fullScreen; final VoidCallback? onInitialized; final Duration? initialPosition; - final VoidCallback? onDoubleTap; const VideoWidget({ super.key, @@ -26,7 +25,6 @@ class VideoWidget extends StatefulWidget { this.fullScreen = false, this.onInitialized, this.initialPosition, - this.onDoubleTap, }); @override @@ -58,7 +56,6 @@ class VideoWidgetState extends State { await videoController.initialize(); if (!mounted) return; - // Rebuild the widget to reflect the initialized state setState(() {}); if (widget.initialPosition != null) { @@ -133,10 +130,7 @@ class VideoWidgetState extends State { mediaContent, if (isInitialized) Positioned.fill( - child: VideoControlsOverlay( - controller: videoController, - onDoubleTap: widget.onDoubleTap, - ), + child: VideoControlsOverlay(controller: videoController), ), ], ), diff --git a/pubspec.lock b/pubspec.lock index 92ebe32..40c807b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -264,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" js: dependency: transitive description: @@ -661,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e764d4..0d4658b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.4.8+69 +version: 1.4.9+70 environment: sdk: ^3.9.0-100.2.beta @@ -37,6 +37,7 @@ dependencies: share_plus: ^11.0.0 flutter_cache_manager: ^3.4.1 pullex: ^1.0.0 + timeago: ^3.7.1 dev_dependencies: flutter_test: