v1.4.2+63
All checks were successful
Flutter Schmutter / build (push) Successful in 3m51s

This commit is contained in:
2025-06-21 16:28:57 +02:00
parent 73a44bb269
commit 7a88c23e57
10 changed files with 285 additions and 156 deletions

View File

@ -4,14 +4,14 @@ import 'package:get/get.dart';
import 'package:encrypt_shared_preferences/provider.dart'; import 'package:encrypt_shared_preferences/provider.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:f0ckapp/models/user.dart';
class AuthController extends GetxController { class AuthController extends GetxController {
final EncryptedSharedPreferencesAsync storage = final EncryptedSharedPreferencesAsync storage =
EncryptedSharedPreferencesAsync.getInstance(); EncryptedSharedPreferencesAsync.getInstance();
RxnString token = RxnString(); RxnString token = RxnString();
RxnInt userId = RxnInt(); Rxn<User> user = Rxn<User>();
RxnString avatarUrl = RxnString();
RxnString username = RxnString();
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
RxnString error = RxnString(); RxnString error = RxnString();
@ -47,9 +47,7 @@ class AuthController extends GetxController {
} catch (_) {} } catch (_) {}
} }
token.value = null; token.value = null;
userId.value = null; user.value = null;
avatarUrl.value = null;
username.value = null;
await storage.remove('token'); await storage.remove('token');
} }
@ -66,11 +64,7 @@ class AuthController extends GetxController {
final dynamic data = json.decode(response.body); final dynamic data = json.decode(response.body);
if (data['token'] != null) { if (data['token'] != null) {
await saveToken(data['token']); await saveToken(data['token']);
userId.value = data['userid']; user.value = User.fromJson(data);
avatarUrl.value = data['avatar'] != null
? 'https://f0ck.me/t/${data['avatar']}.webp'
: null;
this.username.value = data['user'];
return true; return true;
} else { } else {
error.value = 'Kein Token erhalten'; error.value = 'Kein Token erhalten';
@ -95,13 +89,7 @@ class AuthController extends GetxController {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final dynamic data = json.decode(response.body); final dynamic data = json.decode(response.body);
userId.value = data['userid'] != null user.value = User.fromJson(data);
? int.tryParse(data['userid'].toString())
: null;
avatarUrl.value = data['avatar'] != null
? 'https://f0ck.me/t/${data['avatar']}.webp'
: null;
username.value = data['user'];
} else { } else {
await logout(); await logout();
} }

View File

@ -27,6 +27,8 @@ class MediaController extends GetxController {
Rx<PageTransition> transitionType = PageTransition.opacity.obs; Rx<PageTransition> transitionType = PageTransition.opacity.obs;
RxBool drawerSwipeEnabled = true.obs; RxBool drawerSwipeEnabled = true.obs;
RxInt crossAxisCount = 0.obs; RxInt crossAxisCount = 0.obs;
RxInt videoControlsTimerNotifier = 0.obs;
RxInt hideControlsNotifier = 0.obs;
void setTypeIndex(int idx) { void setTypeIndex(int idx) {
typeIndex.value = idx; typeIndex.value = idx;
@ -163,6 +165,9 @@ class MediaController extends GetxController {
fetchInitial(); fetchInitial();
} }
void resetVideoControlsTimer() => videoControlsTimerNotifier.value++;
void hideVideoControls() => hideControlsNotifier.value++;
@override @override
void onInit() async { void onInit() async {
super.onInit(); super.onInit();

View File

@ -186,7 +186,6 @@ final ThemeData f0ck95Theme = ThemeData(
backgroundColor: const Color(0xFFE0E0E0), backgroundColor: const Color(0xFFE0E0E0),
foregroundColor: Colors.black, foregroundColor: Colors.black,
elevation: 4, elevation: 4,
centerTitle: true,
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.black), bodyLarge: TextStyle(color: Colors.black),

17
lib/models/user.dart Normal file
View File

@ -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<String, dynamic> json) {
return User(
id: json['userid'],
username: json['user'],
avatarUrl: json['avatar'] != null
? 'https://f0ck.me/t/${json['avatar']}.webp'
: null,
);
}
}

View File

@ -32,8 +32,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
PageController? _pageController; PageController? _pageController;
final MediaController mediaController = Get.find<MediaController>(); final MediaController mediaController = Get.find<MediaController>();
final AuthController authController = Get.find<AuthController>(); final AuthController authController = Get.find<AuthController>();
final _currentIndex = 0.obs; final RxInt _currentIndex = 0.obs;
final _mediaSaverChannel = const MethodChannel('MediaShit'); final MethodChannel _mediaSaverChannel = const MethodChannel('MediaShit');
final Map<int, bool> _expandedTags = {};
bool _isLoading = true; bool _isLoading = true;
bool _itemNotFound = false; bool _itemNotFound = false;
@ -105,7 +106,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
void _onPageChanged(int idx) { void _onPageChanged(int idx) {
if (idx != _currentIndex.value) { if (idx != _currentIndex.value) {
_currentIndex.value = idx; _currentIndex.value = idx;
final item = mediaController.items[idx]; final MediaItem item = mediaController.items[idx];
if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) { if (item.mime.startsWith('image/') && !_readyItemIds.contains(item.id)) {
setState(() => _readyItemIds.add(item.id)); setState(() => _readyItemIds.add(item.id));
} }
@ -224,46 +225,52 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final MediaItem item = mediaController.items[index]; final MediaItem item = mediaController.items[index];
final bool isReady = _readyItemIds.contains(item.id); final bool isReady = _readyItemIds.contains(item.id);
final bool areTagsExpanded = _expandedTags[item.id] ?? false;
final List<Tag> allTags = item.tags ?? [];
final bool hasMoreTags = allTags.length > 5;
final List<Tag> tagsToShow = areTagsExpanded
? allTags
: allTags.take(5).toList();
return Scaffold( return Obx(
endDrawer: EndDrawer(), () => Scaffold(
endDrawerEnableOpenDragGesture: endDrawer: EndDrawer(),
mediaController.drawerSwipeEnabled.value, endDrawerEnableOpenDragGesture:
appBar: AppBar( mediaController.drawerSwipeEnabled.value,
title: Text('f0ck #${item.id}'), appBar: AppBar(
actions: [ title: Text('f0ck #${item.id}'),
IconButton( actions: [
icon: const Icon(Icons.fullscreen), IconButton(
onPressed: () { icon: const Icon(Icons.fullscreen),
Get.to(
FullScreenMediaView(item: item),
fullscreenDialog: true,
);
},
),
IconButton(
icon: const Icon(Icons.download),
onPressed: () async {
await _downloadMedia(item);
},
),
PopupMenuButton<ShareAction>(
onSelected: (value) => _handleShareAction(value, item),
itemBuilder: (context) => _shareMenuItems,
icon: const Icon(Icons.share),
),
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () { onPressed: () {
Scaffold.of(context).openEndDrawer(); Get.to(
FullScreenMediaView(item: item),
fullscreenDialog: true,
);
}, },
), ),
), IconButton(
], icon: const Icon(Icons.download),
), onPressed: () async {
body: SingleChildScrollView( await _downloadMedia(item);
child: Column( },
),
PopupMenuButton<ShareAction>(
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, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
AnimatedBuilder( AnimatedBuilder(
@ -281,55 +288,78 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
() => _buildMedia(item, index == _currentIndex.value), () => _buildMedia(item, index == _currentIndex.value),
), ),
), ),
const SizedBox(height: 16), Expanded(
if (isReady) child: GestureDetector(
Padding( onTap: () => mediaController.hideVideoControls(),
padding: const EdgeInsets.symmetric(horizontal: 16.0), behavior: HitTestBehavior.translucent,
child: Column( child: Visibility(
crossAxisAlignment: CrossAxisAlignment.center, visible: isReady,
children: [ child: SingleChildScrollView(
Wrap( child: Padding(
spacing: 6.0, padding: const EdgeInsets.all(16.0),
runSpacing: 4.0, child: Column(
alignment: WrapAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
...item.tags?.map( Wrap(
(tag) => ActionTag( spacing: 6.0,
tag, runSpacing: 4.0,
(tag.tag == 'sfw' || tag.tag == 'nsfw') alignment: WrapAlignment.center,
? (onTagTap) => {} children: [
: (onTagTap) { ...tagsToShow.map(
mediaController.setTag(onTagTap); (tag) => ActionTag(
Get.offAllNamed('/'); 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,
); );
}, },
), ),

View File

@ -28,11 +28,11 @@ class EndDrawer extends StatelessWidget {
children: [ children: [
Obx(() { Obx(() {
if (authController.token.value != null && if (authController.token.value != null &&
authController.avatarUrl.value != null) { authController.user.value?.avatarUrl != null) {
return DrawerHeader( return DrawerHeader(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: NetworkImage(authController.avatarUrl.value!), image: NetworkImage(authController.user.value!.avatarUrl!),
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
), ),
@ -58,11 +58,11 @@ class EndDrawer extends StatelessWidget {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
if (authController.username.value != null) if (authController.user.value?.username != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text( child: Text(
'Hamlo ${authController.username.value!}', 'Hamlo ${authController.user.value?.username}',
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
), ),

View File

@ -18,7 +18,7 @@ class FavoriteSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool isFavorite = final bool isFavorite =
item.favorites?.any((f) => f.userId == authController.userId.value) ?? item.favorites?.any((f) => f.userId == authController.user.value?.id) ??
false; false;
return Row( return Row(

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_video_player_plus/cached_video_player_plus.dart'; import 'package:cached_video_player_plus/cached_video_player_plus.dart';
class VideoControlsOverlay extends StatelessWidget { class VideoControlsOverlay extends StatefulWidget {
final CachedVideoPlayerPlusController controller; final CachedVideoPlayerPlusController controller;
final VoidCallback onOverlayTap; final VoidCallback onOverlayTap;
final bool muted; final bool muted;
@ -16,51 +18,102 @@ class VideoControlsOverlay extends StatelessWidget {
required this.onMuteToggle, required this.onMuteToggle,
}); });
@override
State<VideoControlsOverlay> createState() => _VideoControlsOverlayState();
}
class _VideoControlsOverlayState extends State<VideoControlsOverlay> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Row( GestureDetector(
mainAxisAlignment: MainAxisAlignment.center, onTap: widget.onOverlayTap,
children: [ onDoubleTapDown: _handleDoubleTap,
_ControlButton(Icons.replay_10, () { child: Container(color: Colors.transparent),
onOverlayTap(); ),
Duration newPosition = AnimatedOpacity(
controller.value.position - const Duration(seconds: 10); opacity: _showSeekIndicator ? 1.0 : 0.0,
if (newPosition < Duration.zero) newPosition = Duration.zero; duration: const Duration(milliseconds: 200),
controller.seekTo(newPosition); child: Align(
}), alignment: _isRewinding
const SizedBox(width: 40), ? Alignment.centerLeft
_ControlButton( : Alignment.centerRight,
controller.value.isPlaying ? Icons.pause : Icons.play_arrow, child: Padding(
() { padding: const EdgeInsets.symmetric(horizontal: 40.0),
onOverlayTap(); child: Icon(
controller.value.isPlaying _isRewinding
? controller.pause() ? Icons.fast_rewind_rounded
: controller.play(); : Icons.fast_forward_rounded,
}, color: Colors.white70,
size: 64, size: 60,
),
), ),
const SizedBox(width: 40), ),
_ControlButton(Icons.forward_10, () { ),
onOverlayTap(); _ControlButton(
Duration newPosition = widget.controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
controller.value.position + const Duration(seconds: 10); () {
if (newPosition > controller.value.duration) { widget.onOverlayTap();
newPosition = controller.value.duration; widget.controller.value.isPlaying
} ? widget.controller.pause()
controller.seekTo(newPosition); : widget.controller.play();
}), },
], size: 64,
), ),
Positioned( Positioned(
right: 12, right: 12,
bottom: 12, bottom: 12,
child: _ControlButton(muted ? Icons.volume_off : Icons.volume_up, () { child: _ControlButton(
onOverlayTap(); widget.muted ? Icons.volume_off : Icons.volume_up,
onMuteToggle(); () {
}, size: 16), widget.onOverlayTap();
widget.onMuteToggle();
},
size: 16,
),
), ),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
@ -73,16 +126,16 @@ class VideoControlsOverlay extends StatelessWidget {
left: 10, left: 10,
bottom: 12, bottom: 12,
child: Text( 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), style: const TextStyle(color: Colors.white, fontSize: 12),
), ),
), ),
Listener( Listener(
onPointerDown: (_) { onPointerDown: (_) {
onOverlayTap(); widget.onOverlayTap();
}, },
child: VideoProgressIndicator( child: VideoProgressIndicator(
controller, widget.controller,
allowScrubbing: true, allowScrubbing: true,
padding: const EdgeInsets.only(top: 25.0), padding: const EdgeInsets.only(top: 25.0),
colors: const VideoProgressColors( 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( Positioned(
left: left:
(controller.value.position.inMilliseconds / (widget.controller.value.position.inMilliseconds /
controller.value.duration.inMilliseconds) * widget
.controller
.value
.duration
.inMilliseconds) *
MediaQuery.of(context).size.width - MediaQuery.of(context).size.width -
6, 6,
bottom: -4, 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'); String twoDigits(int n) => n.toString().padLeft(2, '0');
return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}"; return "${twoDigits(duration.inMinutes % 60)}:${twoDigits(duration.inSeconds % 60)}";
} }

View File

@ -32,6 +32,8 @@ class _VideoWidgetState extends State<VideoWidget> {
final MediaController controller = Get.find<MediaController>(); final MediaController controller = Get.find<MediaController>();
late CachedVideoPlayerPlusController _controller; late CachedVideoPlayerPlusController _controller;
late Worker _muteWorker; late Worker _muteWorker;
late Worker _timerResetWorker;
late Worker _hideControlsWorker;
bool _showControls = false; bool _showControls = false;
Timer? _hideControlsTimer; Timer? _hideControlsTimer;
@ -44,11 +46,26 @@ class _VideoWidgetState extends State<VideoWidget> {
_controller.setVolume(muted ? 0.0 : 1.0); _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<void> _initController() async { Future<void> _initController() async {
_controller = CachedVideoPlayerPlusController.networkUrl( _controller = CachedVideoPlayerPlusController.networkUrl(
Uri.parse(widget.details.mediaUrl), Uri.parse(widget.details.mediaUrl),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
); );
await _controller.initialize(); await _controller.initialize();
widget.onInitialized?.call(); widget.onInitialized?.call();
@ -78,20 +95,35 @@ class _VideoWidgetState extends State<VideoWidget> {
@override @override
void dispose() { void dispose() {
_muteWorker.dispose(); _muteWorker.dispose();
_timerResetWorker.dispose();
_hideControlsWorker.dispose();
_controller.dispose(); _controller.dispose();
_hideControlsTimer?.cancel(); _hideControlsTimer?.cancel();
super.dispose(); super.dispose();
} }
void _onTap({bool ctrlButton = false}) { void _startHideControlsTimer() {
if (!ctrlButton) { _hideControlsTimer?.cancel();
setState(() => _showControls = !_showControls); _hideControlsTimer = Timer(const Duration(seconds: 3), () {
} if (mounted) {
if (_showControls) {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 2), () {
setState(() => _showControls = false); 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();
} }
} }

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.0-100.2.beta sdk: ^3.9.0-100.2.beta