diff --git a/lib/controller/authcontroller.dart b/lib/controller/authcontroller.dart index 41064a4..6431f42 100644 --- a/lib/controller/authcontroller.dart +++ b/lib/controller/authcontroller.dart @@ -4,14 +4,14 @@ import 'package:get/get.dart'; import 'package:encrypt_shared_preferences/provider.dart'; import 'package:http/http.dart' as http; +import 'package:f0ckapp/models/user.dart'; + class AuthController extends GetxController { final EncryptedSharedPreferencesAsync storage = EncryptedSharedPreferencesAsync.getInstance(); RxnString token = RxnString(); - RxnInt userId = RxnInt(); - RxnString avatarUrl = RxnString(); - RxnString username = RxnString(); + Rxn user = Rxn(); RxBool isLoading = false.obs; RxnString error = RxnString(); @@ -47,9 +47,7 @@ class AuthController extends GetxController { } catch (_) {} } token.value = null; - userId.value = null; - avatarUrl.value = null; - username.value = null; + user.value = null; await storage.remove('token'); } @@ -66,11 +64,7 @@ class AuthController extends GetxController { final dynamic data = json.decode(response.body); if (data['token'] != null) { await saveToken(data['token']); - userId.value = data['userid']; - avatarUrl.value = data['avatar'] != null - ? 'https://f0ck.me/t/${data['avatar']}.webp' - : null; - this.username.value = data['user']; + user.value = User.fromJson(data); return true; } else { error.value = 'Kein Token erhalten'; @@ -95,13 +89,7 @@ class AuthController extends GetxController { ); if (response.statusCode == 200) { final dynamic data = json.decode(response.body); - userId.value = data['userid'] != null - ? int.tryParse(data['userid'].toString()) - : null; - avatarUrl.value = data['avatar'] != null - ? 'https://f0ck.me/t/${data['avatar']}.webp' - : null; - username.value = data['user']; + user.value = User.fromJson(data); } else { await logout(); } diff --git a/lib/controller/mediacontroller.dart b/lib/controller/mediacontroller.dart index 0ef9d58..6c0d1bc 100644 --- a/lib/controller/mediacontroller.dart +++ b/lib/controller/mediacontroller.dart @@ -27,6 +27,8 @@ class MediaController extends GetxController { Rx transitionType = PageTransition.opacity.obs; RxBool drawerSwipeEnabled = true.obs; RxInt crossAxisCount = 0.obs; + RxInt videoControlsTimerNotifier = 0.obs; + RxInt hideControlsNotifier = 0.obs; void setTypeIndex(int idx) { typeIndex.value = idx; @@ -163,6 +165,9 @@ class MediaController extends GetxController { fetchInitial(); } + void resetVideoControlsTimer() => videoControlsTimerNotifier.value++; + void hideVideoControls() => hideControlsNotifier.value++; + @override void onInit() async { super.onInit(); diff --git a/lib/controller/themecontroller.dart b/lib/controller/themecontroller.dart index cdb78f3..f138ab4 100644 --- a/lib/controller/themecontroller.dart +++ b/lib/controller/themecontroller.dart @@ -186,7 +186,6 @@ final ThemeData f0ck95Theme = ThemeData( backgroundColor: const Color(0xFFE0E0E0), foregroundColor: Colors.black, elevation: 4, - centerTitle: true, ), textTheme: const TextTheme( bodyLarge: TextStyle(color: Colors.black), diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..604f614 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,17 @@ +class User { + final int id; + final String username; + final String? avatarUrl; + + User({required this.id, required this.username, this.avatarUrl}); + + factory User.fromJson(Map json) { + return User( + id: json['userid'], + username: json['user'], + avatarUrl: json['avatar'] != null + ? 'https://f0ck.me/t/${json['avatar']}.webp' + : null, + ); + } +} diff --git a/lib/screens/mediadetail.dart b/lib/screens/mediadetail.dart index 8a990a3..46067ee 100644 --- a/lib/screens/mediadetail.dart +++ b/lib/screens/mediadetail.dart @@ -32,8 +32,9 @@ class _MediaDetailScreenState extends State { PageController? _pageController; final MediaController mediaController = Get.find(); final AuthController authController = Get.find(); - final _currentIndex = 0.obs; - final _mediaSaverChannel = const MethodChannel('MediaShit'); + final RxInt _currentIndex = 0.obs; + final MethodChannel _mediaSaverChannel = const MethodChannel('MediaShit'); + final Map _expandedTags = {}; bool _isLoading = true; bool _itemNotFound = false; @@ -105,7 +106,7 @@ class _MediaDetailScreenState extends State { void _onPageChanged(int idx) { if (idx != _currentIndex.value) { _currentIndex.value = idx; - final item = mediaController.items[idx]; + final MediaItem item = mediaController.items[idx]; if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) { setState(() => _readyItemIds.add(item.id)); } @@ -224,46 +225,52 @@ class _MediaDetailScreenState extends State { itemBuilder: (context, index) { final MediaItem item = mediaController.items[index]; final bool isReady = _readyItemIds.contains(item.id); + final bool areTagsExpanded = _expandedTags[item.id] ?? false; + final List allTags = item.tags ?? []; + final bool hasMoreTags = allTags.length > 5; + final List tagsToShow = areTagsExpanded + ? allTags + : allTags.take(5).toList(); - return Scaffold( - endDrawer: EndDrawer(), - endDrawerEnableOpenDragGesture: - mediaController.drawerSwipeEnabled.value, - appBar: AppBar( - title: Text('f0ck #${item.id}'), - actions: [ - IconButton( - icon: const Icon(Icons.fullscreen), - onPressed: () { - Get.to( - FullScreenMediaView(item: item), - fullscreenDialog: true, - ); - }, - ), - IconButton( - icon: const Icon(Icons.download), - onPressed: () async { - await _downloadMedia(item); - }, - ), - PopupMenuButton( - onSelected: (value) => _handleShareAction(value, item), - itemBuilder: (context) => _shareMenuItems, - icon: const Icon(Icons.share), - ), - Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.menu), + return Obx( + () => Scaffold( + endDrawer: EndDrawer(), + endDrawerEnableOpenDragGesture: + mediaController.drawerSwipeEnabled.value, + appBar: AppBar( + title: Text('f0ck #${item.id}'), + actions: [ + IconButton( + icon: const Icon(Icons.fullscreen), onPressed: () { - Scaffold.of(context).openEndDrawer(); + Get.to( + FullScreenMediaView(item: item), + fullscreenDialog: true, + ); }, ), - ), - ], - ), - body: SingleChildScrollView( - child: Column( + IconButton( + icon: const Icon(Icons.download), + onPressed: () async { + await _downloadMedia(item); + }, + ), + PopupMenuButton( + onSelected: (value) => _handleShareAction(value, item), + itemBuilder: (context) => _shareMenuItems, + icon: const Icon(Icons.share), + ), + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + Scaffold.of(context).openEndDrawer(); + }, + ), + ), + ], + ), + body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AnimatedBuilder( @@ -281,55 +288,78 @@ class _MediaDetailScreenState extends State { () => _buildMedia(item, index == _currentIndex.value), ), ), - const SizedBox(height: 16), - if (isReady) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Wrap( - spacing: 6.0, - runSpacing: 4.0, - alignment: WrapAlignment.center, - children: [ - ...item.tags?.map( - (tag) => ActionTag( - tag, - (tag.tag == 'sfw' || tag.tag == 'nsfw') - ? (onTagTap) => {} - : (onTagTap) { - mediaController.setTag(onTagTap); - Get.offAllNamed('/'); - }, + Expanded( + child: GestureDetector( + onTap: () => mediaController.hideVideoControls(), + behavior: HitTestBehavior.translucent, + child: Visibility( + visible: isReady, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Wrap( + spacing: 6.0, + runSpacing: 4.0, + alignment: WrapAlignment.center, + children: [ + ...tagsToShow.map( + (tag) => ActionTag( + tag, + (tag.tag == 'sfw' || tag.tag == 'nsfw') + ? (onTagTap) => {} + : (onTagTap) { + mediaController.setTag( + onTagTap, + ); + Get.offAllNamed('/'); + }, + ), ), - ) ?? - [], - ], - ), - Obx( - () => Visibility( - visible: authController.isLoggedIn, - child: Padding( - padding: const EdgeInsets.only(top: 20.0), - child: FavoriteSection( - item: item, - index: index, + ], ), - ), + if (hasMoreTags) + TextButton( + onPressed: () { + setState( + () => _expandedTags[item.id] = + !areTagsExpanded, + ); + }, + child: Text( + areTagsExpanded + ? 'Weniger anzeigen' + : 'Alle ${allTags.length} Tags anzeigen', + ), + ), + Obx( + () => Visibility( + visible: authController.isLoggedIn, + child: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: FavoriteSection( + item: item, + index: index, + ), + ), + ), + ), + ], ), ), - ], + ), ), - ) - else - const SizedBox.shrink(), + ), + ), + const SafeArea(child: SizedBox.shrink()), ], ), + persistentFooterButtons: mediaController.tag.value != null + ? [TagFooter()] + : null, ), - persistentFooterButtons: mediaController.tag.value != null - ? [TagFooter()] - : null, ); }, ), diff --git a/lib/widgets/end_drawer.dart b/lib/widgets/end_drawer.dart index ecfb14a..ceb2206 100644 --- a/lib/widgets/end_drawer.dart +++ b/lib/widgets/end_drawer.dart @@ -28,11 +28,11 @@ class EndDrawer extends StatelessWidget { children: [ Obx(() { if (authController.token.value != null && - authController.avatarUrl.value != null) { + authController.user.value?.avatarUrl != null) { return DrawerHeader( decoration: BoxDecoration( image: DecorationImage( - image: NetworkImage(authController.avatarUrl.value!), + image: NetworkImage(authController.user.value!.avatarUrl!), fit: BoxFit.cover, alignment: Alignment.topCenter, ), @@ -58,11 +58,11 @@ class EndDrawer extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ - if (authController.username.value != null) + if (authController.user.value?.username != null) Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( - 'Hamlo ${authController.username.value!}', + 'Hamlo ${authController.user.value?.username}', style: const TextStyle(fontWeight: FontWeight.bold), ), ), diff --git a/lib/widgets/favoritesection.dart b/lib/widgets/favoritesection.dart index cf9d930..523ae95 100644 --- a/lib/widgets/favoritesection.dart +++ b/lib/widgets/favoritesection.dart @@ -18,7 +18,7 @@ class FavoriteSection extends StatelessWidget { @override Widget build(BuildContext context) { final bool isFavorite = - item.favorites?.any((f) => f.userId == authController.userId.value) ?? + item.favorites?.any((f) => f.userId == authController.user.value?.id) ?? false; return Row( diff --git a/lib/widgets/video_controls_overlay.dart b/lib/widgets/video_controls_overlay.dart index a436277..ac3f473 100644 --- a/lib/widgets/video_controls_overlay.dart +++ b/lib/widgets/video_controls_overlay.dart @@ -1,8 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:cached_video_player_plus/cached_video_player_plus.dart'; -class VideoControlsOverlay extends StatelessWidget { +class VideoControlsOverlay extends StatefulWidget { final CachedVideoPlayerPlusController controller; final VoidCallback onOverlayTap; final bool muted; @@ -16,51 +18,102 @@ class VideoControlsOverlay extends StatelessWidget { required this.onMuteToggle, }); + @override + State createState() => _VideoControlsOverlayState(); +} + +class _VideoControlsOverlayState extends State { + bool _showSeekIndicator = false; + bool _isRewinding = false; + Timer? _hideTimer; + + @override + void dispose() { + _hideTimer?.cancel(); + super.dispose(); + } + + void _handleDoubleTap(TapDownDetails details) { + final screenWidth = MediaQuery.of(context).size.width; + final isRewind = details.globalPosition.dx < screenWidth / 2; + + Future(() { + if (isRewind) { + final newPosition = + widget.controller.value.position - const Duration(seconds: 10); + widget.controller.seekTo( + newPosition < Duration.zero ? Duration.zero : newPosition, + ); + } else { + final newPosition = + widget.controller.value.position + const Duration(seconds: 10); + final duration = widget.controller.value.duration; + widget.controller.seekTo( + newPosition > duration ? duration : newPosition, + ); + } + }); + + _hideTimer?.cancel(); + setState(() { + _showSeekIndicator = true; + _isRewinding = isRewind; + }); + _hideTimer = Timer(const Duration(milliseconds: 500), () { + setState(() => _showSeekIndicator = false); + }); + } + @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _ControlButton(Icons.replay_10, () { - onOverlayTap(); - Duration newPosition = - controller.value.position - const Duration(seconds: 10); - if (newPosition < Duration.zero) newPosition = Duration.zero; - controller.seekTo(newPosition); - }), - const SizedBox(width: 40), - _ControlButton( - controller.value.isPlaying ? Icons.pause : Icons.play_arrow, - () { - onOverlayTap(); - controller.value.isPlaying - ? controller.pause() - : controller.play(); - }, - size: 64, + 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, + ), ), - const SizedBox(width: 40), - _ControlButton(Icons.forward_10, () { - onOverlayTap(); - Duration newPosition = - controller.value.position + const Duration(seconds: 10); - if (newPosition > controller.value.duration) { - newPosition = controller.value.duration; - } - controller.seekTo(newPosition); - }), - ], + ), + ), + _ControlButton( + widget.controller.value.isPlaying ? Icons.pause : Icons.play_arrow, + () { + widget.onOverlayTap(); + widget.controller.value.isPlaying + ? widget.controller.pause() + : widget.controller.play(); + }, + size: 64, ), Positioned( right: 12, bottom: 12, - child: _ControlButton(muted ? Icons.volume_off : Icons.volume_up, () { - onOverlayTap(); - onMuteToggle(); - }, size: 16), + child: _ControlButton( + widget.muted ? Icons.volume_off : Icons.volume_up, + () { + widget.onOverlayTap(); + widget.onMuteToggle(); + }, + size: 16, + ), ), Align( alignment: Alignment.bottomCenter, @@ -73,16 +126,16 @@ class VideoControlsOverlay extends StatelessWidget { left: 10, bottom: 12, child: Text( - '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', + '${_formatDuration(widget.controller.value.position)} / ${_formatDuration(widget.controller.value.duration)}', style: const TextStyle(color: Colors.white, fontSize: 12), ), ), Listener( onPointerDown: (_) { - onOverlayTap(); + widget.onOverlayTap(); }, child: VideoProgressIndicator( - controller, + widget.controller, allowScrubbing: true, padding: const EdgeInsets.only(top: 25.0), colors: const VideoProgressColors( @@ -92,11 +145,15 @@ class VideoControlsOverlay extends StatelessWidget { ), ), ), - if (controller.value.duration.inMilliseconds > 0) + if (widget.controller.value.duration.inMilliseconds > 0) Positioned( left: - (controller.value.position.inMilliseconds / - controller.value.duration.inMilliseconds) * + (widget.controller.value.position.inMilliseconds / + widget + .controller + .value + .duration + .inMilliseconds) * MediaQuery.of(context).size.width - 6, bottom: -4, @@ -118,7 +175,8 @@ class VideoControlsOverlay extends StatelessWidget { ); } - String _formatDuration(Duration duration) { + String _formatDuration(Duration? duration) { + if (duration == null) return '00:00'; String twoDigits(int n) => n.toString().padLeft(2, '0'); return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}"; } diff --git a/lib/widgets/video_widget.dart b/lib/widgets/video_widget.dart index 65b0032..f5f63e4 100644 --- a/lib/widgets/video_widget.dart +++ b/lib/widgets/video_widget.dart @@ -32,6 +32,8 @@ class _VideoWidgetState extends State { final MediaController controller = Get.find(); late CachedVideoPlayerPlusController _controller; late Worker _muteWorker; + late Worker _timerResetWorker; + late Worker _hideControlsWorker; bool _showControls = false; Timer? _hideControlsTimer; @@ -44,11 +46,26 @@ class _VideoWidgetState extends State { _controller.setVolume(muted ? 0.0 : 1.0); } }); + _timerResetWorker = ever(controller.videoControlsTimerNotifier, (_) { + if (widget.isActive && mounted) { + if (!_showControls) { + setState(() => _showControls = true); + } + _startHideControlsTimer(); + } + }); + _hideControlsWorker = ever(controller.hideControlsNotifier, (_) { + if (mounted && _showControls) { + setState(() => _showControls = false); + _hideControlsTimer?.cancel(); + } + }); } Future _initController() async { _controller = CachedVideoPlayerPlusController.networkUrl( Uri.parse(widget.details.mediaUrl), + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); await _controller.initialize(); widget.onInitialized?.call(); @@ -78,20 +95,35 @@ class _VideoWidgetState extends State { @override void dispose() { _muteWorker.dispose(); + _timerResetWorker.dispose(); + _hideControlsWorker.dispose(); _controller.dispose(); _hideControlsTimer?.cancel(); super.dispose(); } - void _onTap({bool ctrlButton = false}) { - if (!ctrlButton) { - setState(() => _showControls = !_showControls); - } - if (_showControls) { - _hideControlsTimer?.cancel(); - _hideControlsTimer = Timer(const Duration(seconds: 2), () { + 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(); } } diff --git a/pubspec.yaml b/pubspec.yaml index 15e95fd..060c2eb 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.1+62 +version: 1.4.2+63 environment: sdk: ^3.9.0-100.2.beta