mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-15 10:15:58 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
343
lib/screens/photo_viewer/photo_viewer_controls.dart
Normal file
343
lib/screens/photo_viewer/photo_viewer_controls.dart
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/shared/input_fields.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:fladder/widgets/shared/elevated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/progress_floating_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:square_progress_indicator/square_progress_indicator.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class PhotoViewerControls extends ConsumerStatefulWidget {
|
||||
final EdgeInsets padding;
|
||||
final PhotoModel photo;
|
||||
final int itemCount;
|
||||
final bool loadingMoreItems;
|
||||
final int currentIndex;
|
||||
final ValueChanged<PhotoModel> onPhotoChanged;
|
||||
final Function() openOptions;
|
||||
final ExtendedPageController pageController;
|
||||
final Function(bool? value)? toggleOverlay;
|
||||
const PhotoViewerControls({
|
||||
required this.padding,
|
||||
required this.photo,
|
||||
required this.pageController,
|
||||
required this.loadingMoreItems,
|
||||
required this.openOptions,
|
||||
required this.onPhotoChanged,
|
||||
required this.itemCount,
|
||||
required this.currentIndex,
|
||||
this.toggleOverlay,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PhotoViewerControllsState();
|
||||
}
|
||||
|
||||
class _PhotoViewerControllsState extends ConsumerState<PhotoViewerControls> with WindowListener {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
late int currentPage = widget.pageController.page?.round() ?? 0;
|
||||
double dragUpDelta = 0.0;
|
||||
|
||||
final controller = TextEditingController();
|
||||
late final timerController =
|
||||
RestarableTimerController(ref.read(photoViewSettingsProvider).timer, const Duration(milliseconds: 32), () {
|
||||
if (widget.pageController.page == widget.itemCount - 1) {
|
||||
widget.pageController.animateToPage(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
} else {
|
||||
widget.pageController.nextPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
}
|
||||
});
|
||||
|
||||
void _resetOnScroll() {
|
||||
if (currentPage != widget.pageController.page?.round()) {
|
||||
currentPage = widget.pageController.page?.round() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => () {
|
||||
if (AdaptiveLayout.of(context).isDesktop) focusNode.requestFocus();
|
||||
});
|
||||
|
||||
windowManager.addListener(this);
|
||||
widget.pageController.addListener(
|
||||
() {
|
||||
_resetOnScroll();
|
||||
timerController.reset();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
timerController.cancel();
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timerController.dispose();
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (AdaptiveLayout.of(context).isDesktop) focusNode.requestFocus();
|
||||
|
||||
final gradient = [
|
||||
Colors.black.withOpacity(0.6),
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.black.withOpacity(0.1),
|
||||
Colors.black.withOpacity(0.0),
|
||||
];
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
return PopScope(
|
||||
onPopInvoked: (popped) async {
|
||||
await WakelockPlus.disable();
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (value) {
|
||||
if (value is KeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
throttler.run(() => widget.pageController
|
||||
.previousPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut));
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
throttler.run(() =>
|
||||
widget.pageController.nextPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut));
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.keyK) {
|
||||
timerController.playPause();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.space) {
|
||||
widget.toggleOverlay?.call(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
widthFactor: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradient,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: widget.padding.top),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop) const SizedBox(height: 25),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12)
|
||||
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
ElevatedIconButton(
|
||||
onPressed: () => Navigator.of(context).pop(widget.pageController.page?.toInt()),
|
||||
icon: getBackIcon(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: widget.photo.name,
|
||||
child: Text(
|
||||
widget.photo.name,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, shadows: [
|
||||
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Colors.black.withOpacity(0.7)),
|
||||
BoxShadow(blurRadius: 4, spreadRadius: 4, color: Colors.black.withOpacity(0.4)),
|
||||
BoxShadow(blurRadius: 20, spreadRadius: 6, color: Colors.black.withOpacity(0.2)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
child: SquareProgressIndicator(
|
||||
value: widget.currentIndex / (widget.itemCount - 1),
|
||||
borderRadius: 7,
|
||||
clockwise: false,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(9),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"${widget.currentIndex + 1} / ${widget.loadingMoreItems ? "-" : "${widget.itemCount}"} ",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (widget.loadingMoreItems)
|
||||
SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
].addInBetween(SizedBox(width: 6)),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: FlatButton(
|
||||
borderRadiusGeometry: BorderRadius.circular(8),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
child: SizedBox(
|
||||
width: 125,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.localized.goTo,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
IntInputField(
|
||||
controller: TextEditingController(
|
||||
text: (widget.currentIndex + 1).toString()),
|
||||
onSubmitted: (value) {
|
||||
final position =
|
||||
((value ?? 0) - 1).clamp(0, widget.itemCount - 1);
|
||||
widget.pageController.jumpToPage(position);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradient.reversed.toList(),
|
||||
),
|
||||
),
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: widget.padding.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.all(8.0).add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedIconButton(
|
||||
onPressed: widget.openOptions,
|
||||
icon: IconsaxOutline.more_2,
|
||||
),
|
||||
Spacer(),
|
||||
ElevatedIconButton(
|
||||
onPressed: markAsFavourite,
|
||||
color: widget.photo.userData.isFavourite ? Colors.red : null,
|
||||
icon: widget.photo.userData.isFavourite ? IconsaxBold.heart : IconsaxOutline.heart,
|
||||
),
|
||||
ProgressFloatingButton(
|
||||
controller: timerController,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void markAsFavourite() {
|
||||
ref.read(userProvider.notifier).setAsFavorite(!widget.photo.userData.isFavourite, widget.photo.id);
|
||||
|
||||
widget.onPhotoChanged(widget.photo
|
||||
.copyWith(userData: widget.photo.userData.copyWith(isFavourite: !widget.photo.userData.isFavourite)));
|
||||
}
|
||||
|
||||
Future<void> sharePhoto() async {
|
||||
final file = await DefaultCacheManager().getSingleFile(widget.photo.downloadPath(ref));
|
||||
await Share.shareXFiles([
|
||||
XFile(
|
||||
file.path,
|
||||
),
|
||||
]);
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
526
lib/screens/photo_viewer/photo_viewer_screen.dart
Normal file
526
lib/screens/photo_viewer/photo_viewer_screen.dart
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/main.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/photo_viewer_controls.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/simple_video_player.dart';
|
||||
import 'package:fladder/screens/shared/default_titlebar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
|
||||
import 'package:fladder/widgets/shared/animated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/elevated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/hover_widget.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PhotoViewerScreen extends ConsumerStatefulWidget {
|
||||
final List<PhotoModel>? items;
|
||||
final int indexOfSelected;
|
||||
final Future<List<PhotoModel>>? loadingItems;
|
||||
const PhotoViewerScreen({
|
||||
this.items,
|
||||
this.indexOfSelected = 0,
|
||||
this.loadingItems,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PhotoViewerScreenState();
|
||||
}
|
||||
|
||||
class _PhotoViewerScreenState extends ConsumerState<PhotoViewerScreen> with WidgetsBindingObserver {
|
||||
late List<PhotoModel> photos = widget.items ?? [];
|
||||
late final ExtendedPageController controller = ExtendedPageController(initialPage: widget.indexOfSelected);
|
||||
double currentScale = 1.0;
|
||||
late int currentPage = widget.indexOfSelected.clamp(0, photos.length - 1);
|
||||
bool showInterface = true;
|
||||
bool toolbarHover = false;
|
||||
|
||||
late final double topPadding = MediaQuery.of(context).viewPadding.top;
|
||||
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||
bool loadingItems = false;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(timeStamp) async {
|
||||
cacheNeighbors(widget.indexOfSelected, 2);
|
||||
if (widget.loadingItems != null) {
|
||||
setState(() {
|
||||
loadingItems = true;
|
||||
});
|
||||
|
||||
final newItems = await Future.value(widget.loadingItems);
|
||||
|
||||
setState(() {
|
||||
photos = {...photos, ...newItems}.toList();
|
||||
loadingItems = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> removePhoto(ItemBaseModel photo) async {
|
||||
if (photos.length == 1) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
photos.remove(photo);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showOverlay({bool? show}) {
|
||||
setState(() {
|
||||
showInterface = show ?? !showInterface;
|
||||
});
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: [],
|
||||
);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
final gestureConfig = GestureConfig(
|
||||
inertialSpeed: 300,
|
||||
initialAlignment: InitialAlignment.center,
|
||||
inPageView: true,
|
||||
initialScale: 1.0,
|
||||
maxScale: 6,
|
||||
minScale: 1,
|
||||
animationMinScale: 0.1,
|
||||
animationMaxScale: 7,
|
||||
cacheGesture: false,
|
||||
reverseMousePointerScrollDirection: true,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: ThemesData.of(context).dark,
|
||||
child: PopScope(
|
||||
onPopInvoked: (popped) async => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []),
|
||||
child: MouseRegion(
|
||||
opaque: AdaptiveLayout.of(context).isDesktop,
|
||||
onEnter: (event) => setState(() => _showOverlay(show: true)),
|
||||
onExit: (event) => setState(() => _showOverlay(show: false)),
|
||||
child: Scaffold(
|
||||
appBar: photos.isEmpty
|
||||
? FladderAppbar(
|
||||
automaticallyImplyLeading: true,
|
||||
)
|
||||
: null,
|
||||
body: photos.isEmpty
|
||||
? Center(
|
||||
child: Text(context.localized.noItemsToShow),
|
||||
)
|
||||
: buildViewer(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildViewer() {
|
||||
final currentPhoto = photos[currentPage];
|
||||
final imageHash = currentPhoto.images?.primary?.hash;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode)) && imageHash != null
|
||||
? Opacity(
|
||||
key: Key(currentPhoto.id),
|
||||
opacity: 0.7,
|
||||
child: SizedBox.expand(
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.high,
|
||||
image: BlurHashImage(imageHash),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTapUp: (details) => _showOverlay(),
|
||||
onDoubleTapDown: AdaptiveLayout.of(context).isDesktop
|
||||
? null
|
||||
: (details) async {
|
||||
await openOptions(
|
||||
context,
|
||||
currentPhoto,
|
||||
removePhoto,
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
if (currentPhoto.userData.isFavourite == true) {
|
||||
HapticFeedback.lightImpact();
|
||||
} else {
|
||||
markAsFavourite(currentPhoto, value: true);
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
},
|
||||
child: ExtendedImageGesturePageView.builder(
|
||||
itemCount: photos.length,
|
||||
controller: controller,
|
||||
onPageChanged: (index) => setState(() {
|
||||
currentPage = index;
|
||||
cacheNeighbors(index, 3);
|
||||
SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: []);
|
||||
}),
|
||||
itemBuilder: (context, index) {
|
||||
final photo = photos[index];
|
||||
return ExtendedImage(
|
||||
key: Key(photo.id),
|
||||
fit: BoxFit.contain,
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: (state) => gestureConfig,
|
||||
handleLoadingProgress: true,
|
||||
onDoubleTap: (state) {
|
||||
return;
|
||||
},
|
||||
gaplessPlayback: true,
|
||||
loadStateChanged: (state) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (state.extendedImageLoadState != LoadState.completed)
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.contain,
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
imageUrl: photo.thumbnail?.primary?.path ?? "",
|
||||
),
|
||||
),
|
||||
switch (state.extendedImageLoadState) {
|
||||
LoadState.loading => Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
),
|
||||
LoadState.completed => switch (photo.internalType) {
|
||||
FladderItemType.video => SimpleVideoPlayer(
|
||||
onTapped: _showOverlay,
|
||||
showOverlay: showInterface,
|
||||
video: photos[index],
|
||||
),
|
||||
_ => state.completedWidget,
|
||||
},
|
||||
LoadState.failed || _ => Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24).copyWith(top: topPadding + 85),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.localized.failedToLoadImage,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => state.reLoadImage(),
|
||||
child: Text(context.localized.retry),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
],
|
||||
);
|
||||
},
|
||||
image: CachedNetworkImageProvider(
|
||||
photo.images?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !showInterface,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showInterface ? 1 : 0,
|
||||
child: PhotoViewerControls(
|
||||
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
|
||||
currentIndex: currentPage,
|
||||
itemCount: photos.length,
|
||||
loadingMoreItems: loadingItems,
|
||||
pageController: controller,
|
||||
photo: currentPhoto,
|
||||
toggleOverlay: (value) => setState(() => showInterface = value ?? !showInterface),
|
||||
openOptions: () => openOptions(context, currentPhoto, removePhoto),
|
||||
onPhotoChanged: (photo) {
|
||||
setState(() {
|
||||
int index = photos.indexOf(currentPhoto);
|
||||
photos.remove(currentPhoto);
|
||||
photos.insert(index, photo);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) ...{
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: HoverWidget(
|
||||
child: (visible) => AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
width: 50,
|
||||
height: MediaQuery.sizeOf(context).height * 0.5,
|
||||
child: IconButton.filledTonal(
|
||||
style:
|
||||
IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () =>
|
||||
controller.nextPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut),
|
||||
icon: const Icon(IconsaxBold.arrow_right_1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: HoverWidget(
|
||||
child: (visible) => AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
width: 50,
|
||||
height: MediaQuery.sizeOf(context).height * 0.5,
|
||||
child: IconButton.filledTonal(
|
||||
style:
|
||||
IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () =>
|
||||
controller.previousPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut),
|
||||
icon: const Icon(IconsaxBold.arrow_left),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showInterface
|
||||
? 1
|
||||
: toolbarHover
|
||||
? 1
|
||||
: 0,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
widthFactor: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.black.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
height: 45,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() => toolbarHover = true),
|
||||
onExit: (event) => setState(() => toolbarHover = false),
|
||||
child: const Column(
|
||||
children: [
|
||||
DefaultTitleBar(
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: showInterface ? 0 : 1,
|
||||
child: AnimatedVisibilityIcon(
|
||||
key: Key(currentPhoto.id),
|
||||
isFilled: currentPhoto.userData.isFavourite,
|
||||
filledIcon: IconsaxBold.heart,
|
||||
outlinedIcon: IconsaxOutline.heart,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openOptions(BuildContext context, PhotoModel currentPhoto, Function(ItemBaseModel item) onRemove) =>
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(builder: (context, ref, child) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.loop,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(repeat: !state.repeat)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.repeat))
|
||||
? IconsaxOutline.repeat
|
||||
: IconsaxOutline.repeate_one,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.audio,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(mute: !state.mute)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.mute))
|
||||
? IconsaxOutline.volume_slash
|
||||
: IconsaxOutline.volume_high,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.autoPlay,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(autoPlay: !state.autoPlay)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.autoPlay))
|
||||
? IconsaxOutline.play_remove
|
||||
: IconsaxOutline.play,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.backgroundBlur,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(theaterMode: !state.theaterMode)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode))
|
||||
? IconsaxOutline.filter_remove
|
||||
: IconsaxOutline.filter,
|
||||
),
|
||||
].addInBetween(const SizedBox(width: 18)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
...currentPhoto
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.details,
|
||||
ItemActions.markPlayed,
|
||||
ItemActions.markUnplayed,
|
||||
},
|
||||
onDeleteSuccesFully: onRemove,
|
||||
)
|
||||
.listTileItems(context, useIcons: true),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
void markAsFavourite(PhotoModel photo, {bool? value}) {
|
||||
ref.read(userProvider.notifier).setAsFavorite(value ?? !photo.userData.isFavourite, photo.id);
|
||||
|
||||
setState(() {
|
||||
int index = photos.indexOf(photo);
|
||||
photos.remove(photo);
|
||||
photos.insert(
|
||||
index, photo.copyWith(userData: photo.userData.copyWith(isFavourite: value ?? !photo.userData.isFavourite)));
|
||||
});
|
||||
}
|
||||
|
||||
void cacheNeighbors(int index, int range) {
|
||||
photos
|
||||
.getRange((index - range).clamp(0, photos.length - 1), (index + range).clamp(0, photos.length - 1))
|
||||
.forEach((element) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
element.thumbnail?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
context);
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
element.images?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
context);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
261
lib/screens/photo_viewer/simple_video_player.dart
Normal file
261
lib/screens/photo_viewer/simple_video_player.dart
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/util/duration_extensions.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class SimpleVideoPlayer extends ConsumerStatefulWidget {
|
||||
final PhotoModel video;
|
||||
final bool showOverlay;
|
||||
final VoidCallback onTapped;
|
||||
const SimpleVideoPlayer({required this.video, required this.showOverlay, required this.onTapped, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SimpleVideoPlayerState();
|
||||
}
|
||||
|
||||
class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with WindowListener, WidgetsBindingObserver {
|
||||
final Player player = Player(
|
||||
configuration: const PlayerConfiguration(libass: true),
|
||||
);
|
||||
late VideoController controller = VideoController(player);
|
||||
late String videoUrl = "";
|
||||
|
||||
bool playing = false;
|
||||
bool wasPlaying = false;
|
||||
Duration position = Duration.zero;
|
||||
Duration lastPosition = Duration.zero;
|
||||
Duration duration = Duration.zero;
|
||||
|
||||
List<StreamSubscription> subscriptions = [];
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (playing) player.play();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
if (playing) player.pause();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
windowManager.addListener(this);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
playing = player.state.playing;
|
||||
position = player.state.position;
|
||||
duration = player.state.duration;
|
||||
Future.microtask(() async => {_init()});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
if (playing) player.pause();
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
final Map<String, String?> directOptions = {
|
||||
'Static': 'true',
|
||||
'mediaSourceId': widget.video.id,
|
||||
'api_key': ref.read(userProvider)?.credentials.token,
|
||||
};
|
||||
|
||||
final params = Uri(queryParameters: directOptions).query;
|
||||
|
||||
videoUrl = '${ref.read(userProvider)?.server ?? ""}/Videos/${widget.video.id}/stream?$params';
|
||||
|
||||
subscriptions.addAll(
|
||||
[
|
||||
player.stream.playing.listen((event) {
|
||||
setState(() {
|
||||
playing = event;
|
||||
});
|
||||
if (playing) {
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}),
|
||||
player.stream.position.listen((event) {
|
||||
setState(() {
|
||||
position = event;
|
||||
});
|
||||
}),
|
||||
player.stream.completed.listen((event) {
|
||||
if (event) {
|
||||
_restartVideo();
|
||||
}
|
||||
}),
|
||||
player.stream.duration.listen((event) {
|
||||
setState(() {
|
||||
duration = event;
|
||||
});
|
||||
}),
|
||||
],
|
||||
);
|
||||
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
|
||||
await player.open(Media(videoUrl), play: !ref.watch(photoViewSettingsProvider).autoPlay);
|
||||
}
|
||||
|
||||
void _restartVideo() {
|
||||
if (ref.read(photoViewSettingsProvider.select((value) => value.repeat))) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Future.microtask(() async {
|
||||
await player.dispose();
|
||||
});
|
||||
for (final s in subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, shadows: [const Shadow(blurRadius: 2)]);
|
||||
ref.listen(
|
||||
photoViewSettingsProvider.select((value) => value.mute),
|
||||
(previous, next) {
|
||||
if (previous != next) {
|
||||
player.setVolume(next ? 0 : 100);
|
||||
}
|
||||
},
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: widget.onTapped,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FladderImage(
|
||||
image: widget.video.thumbnail?.primary,
|
||||
enableBlur: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
//Fixes small overlay problems with thumbnail
|
||||
Transform.scale(
|
||||
scaleY: 1.004,
|
||||
child: Video(
|
||||
fit: BoxFit.contain,
|
||||
fill: const Color.fromARGB(0, 123, 62, 62),
|
||||
controller: controller,
|
||||
controls: NoVideoControls,
|
||||
wakelock: false,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !widget.showOverlay,
|
||||
child: AnimatedOpacity(
|
||||
opacity: widget.showOverlay ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12)
|
||||
.add(EdgeInsets.only(bottom: 80 + MediaQuery.of(context).padding.bottom)),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FladderSlider(
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
value: position.inMilliseconds.toDouble().clamp(
|
||||
0,
|
||||
duration.inMilliseconds.toDouble(),
|
||||
),
|
||||
onChangeEnd: (e) async {
|
||||
await player.seek(Duration(milliseconds: e ~/ 1));
|
||||
if (wasPlaying) {
|
||||
player.play();
|
||||
}
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
wasPlaying = player.state.playing;
|
||||
player.pause();
|
||||
},
|
||||
onChanged: (e) {
|
||||
setState(() => position = Duration(milliseconds: e ~/ 1));
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(position.readAbleDuration, style: textStyle),
|
||||
const Spacer(),
|
||||
Text((duration - position).readAbleDuration, style: textStyle),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () {
|
||||
player.playOrPause();
|
||||
},
|
||||
icon: Icon(
|
||||
player.state.playing ? IconsaxBold.pause_circle : IconsaxBold.play_circle,
|
||||
shadows: [
|
||||
BoxShadow(blurRadius: 16, spreadRadius: 2, color: Colors.black.withOpacity(0.15))
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue