Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View 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();
}
}

View 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);
}
});
}
}

View 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))
],
),
)
],
),
),
),
),
],
),
),
),
],
),
);
}
}