mirror of
https://github.com/Art051/immich.git
synced 2025-08-11 19:29:00 +00:00
98
mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart
Normal file
98
mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
class AdvancedBottomSheet extends HookConsumerWidget {
|
||||
final Asset assetDetail;
|
||||
|
||||
const AdvancedBottomSheet({super.key, required this.assetDetail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// One column
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Align(
|
||||
child: Text(
|
||||
"ADVANCED INFO",
|
||||
style: TextStyle(fontSize: 12.0),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32.0),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 16.0,
|
||||
left: 16,
|
||||
top: 8,
|
||||
bottom: 16,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: assetDetail.toString(),
|
||||
),
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Copied to clipboard",
|
||||
style:
|
||||
context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
size: 16.0,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
assetDetail.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: "Inconsolata",
|
||||
),
|
||||
showCursor: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
mobile/lib/widgets/asset_viewer/animated_play_pause.dart
Normal file
57
mobile/lib/widgets/asset_viewer/animated_play_pause.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that animates implicitly between a play and a pause icon.
|
||||
class AnimatedPlayPause extends StatefulWidget {
|
||||
const AnimatedPlayPause({
|
||||
super.key,
|
||||
required this.playing,
|
||||
this.size,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final double? size;
|
||||
final bool playing;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||
}
|
||||
|
||||
class AnimatedPlayPauseState extends State<AnimatedPlayPause>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final animationController = AnimationController(
|
||||
vsync: this,
|
||||
value: widget.playing ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedPlayPause oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.playing != oldWidget.playing) {
|
||||
if (widget.playing) {
|
||||
animationController.forward();
|
||||
} else {
|
||||
animationController.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
335
mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
Normal file
335
mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class BottomGalleryBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool showStack;
|
||||
final int stackIndex;
|
||||
final int totalAssets;
|
||||
final bool showVideoPlayerControls;
|
||||
final PageController controller;
|
||||
|
||||
const BottomGalleryBar({
|
||||
super.key,
|
||||
required this.showStack,
|
||||
required this.stackIndex,
|
||||
required this.asset,
|
||||
required this.controller,
|
||||
required this.totalAssets,
|
||||
required this.showVideoPlayerControls,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
|
||||
final stack = showStack && asset.stackChildrenCount > 0
|
||||
? ref.watch(assetStackStateProvider(asset))
|
||||
: <Asset>[];
|
||||
final stackElements = showStack ? [asset, ...stack] : <Asset>[];
|
||||
bool isParent = stackIndex == -1 || stackIndex == 0;
|
||||
final navStack = AutoRouter.of(context).stackData;
|
||||
final isTrashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final isFromTrash = isTrashEnabled &&
|
||||
navStack.length > 2 &&
|
||||
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
||||
// !!!! itemsList and actionlist should always be in sync
|
||||
final itemsList = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(
|
||||
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
),
|
||||
label: 'control_bottom_app_bar_share'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||
),
|
||||
if (isOwner)
|
||||
asset.isArchived
|
||||
? BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.unarchive_rounded),
|
||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
)
|
||||
: BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
label: 'control_bottom_app_bar_archive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
||||
),
|
||||
if (isOwner && stack.isNotEmpty)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.burst_mode_outlined),
|
||||
label: 'control_bottom_app_bar_stack'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
||||
),
|
||||
if (isOwner)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: 'control_bottom_app_bar_delete'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||
),
|
||||
if (!isOwner)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.download_outlined),
|
||||
label: 'download'.tr(),
|
||||
tooltip: 'download'.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
void removeAssetFromStack() {
|
||||
if (stackIndex > 0 && showStack) {
|
||||
ref
|
||||
.read(assetStackStateProvider(asset).notifier)
|
||||
.removeChild(stackIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void handleDelete() async {
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{asset},
|
||||
force: force,
|
||||
);
|
||||
if (isDeleted && isParent) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
context.popRoute();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
return isDeleted;
|
||||
}
|
||||
|
||||
// Asset is trashed
|
||||
if (isTrashEnabled && !isFromTrash) {
|
||||
final isDeleted = await onDelete(false);
|
||||
if (isDeleted) {
|
||||
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
||||
if (context.mounted && asset.isRemote && isParent) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'Asset trashed',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
removeAssetFromStack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset is permanently removed
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () async {
|
||||
final isDeleted = await onDelete(true);
|
||||
if (isDeleted) {
|
||||
removeAssetFromStack();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void showStackActionItems() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
enableDrag: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isParent)
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.bookmark_border_outlined,
|
||||
size: 24,
|
||||
),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(assetStackServiceProvider)
|
||||
.updateStackParent(
|
||||
asset,
|
||||
stackElements.elementAt(stackIndex),
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
},
|
||||
title: const Text(
|
||||
"viewer_stack_use_as_main_asset",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.copy_all_outlined,
|
||||
size: 24,
|
||||
),
|
||||
onTap: () async {
|
||||
if (isParent) {
|
||||
await ref
|
||||
.read(assetStackServiceProvider)
|
||||
.updateStackParent(
|
||||
asset,
|
||||
stackElements
|
||||
.elementAt(1), // Next asset as parent
|
||||
);
|
||||
// Remove itself from stack
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
stackElements.elementAt(1),
|
||||
childrenToRemove: [asset],
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
} else {
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
asset,
|
||||
childrenToRemove: [
|
||||
stackElements.elementAt(stackIndex),
|
||||
],
|
||||
);
|
||||
removeAssetFromStack();
|
||||
ctx.pop();
|
||||
}
|
||||
},
|
||||
title: const Text(
|
||||
"viewer_remove_from_stack",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.filter_none_outlined,
|
||||
size: 18,
|
||||
),
|
||||
onTap: () async {
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
asset,
|
||||
childrenToRemove: stack,
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
},
|
||||
title: const Text(
|
||||
"viewer_unstack",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
shareAsset() {
|
||||
if (asset.isOffline) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'asset_action_share_err_offline'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
||||
}
|
||||
|
||||
handleArchive() {
|
||||
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
||||
if (isParent) {
|
||||
context.popRoute();
|
||||
return;
|
||||
}
|
||||
removeAssetFromStack();
|
||||
}
|
||||
|
||||
handleDownload() {
|
||||
if (asset.isLocal) {
|
||||
return;
|
||||
}
|
||||
if (asset.isOffline) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'asset_action_share_err_offline'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
List<Function(int)> actionslist = [
|
||||
(_) => shareAsset(),
|
||||
if (isOwner) (_) => handleArchive(),
|
||||
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
|
||||
if (isOwner) (_) => handleDelete(),
|
||||
if (!isOwner) (_) => handleDownload(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: showVideoPlayerControls,
|
||||
child: const VideoControls(),
|
||||
),
|
||||
BottomNavigationBar(
|
||||
backgroundColor: Colors.black.withOpacity(0.4),
|
||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||
unselectedLabelStyle: const TextStyle(color: Colors.black),
|
||||
selectedLabelStyle: const TextStyle(color: Colors.black),
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
items: itemsList,
|
||||
onTap: (index) {
|
||||
if (index < actionslist.length) {
|
||||
actionslist[index].call(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
mobile/lib/widgets/asset_viewer/center_play_button.dart
Normal file
53
mobile/lib/widgets/asset_viewer/center_play_button.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||
|
||||
class CenterPlayButton extends StatelessWidget {
|
||||
const CenterPlayButton({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
this.iconColor,
|
||||
required this.show,
|
||||
required this.isPlaying,
|
||||
required this.isFinished,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color? iconColor;
|
||||
final bool show;
|
||||
final bool isPlaying;
|
||||
final bool isFinished;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: UnconstrainedBox(
|
||||
child: AnimatedOpacity(
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 32,
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
icon: isFinished
|
||||
? Icon(Icons.replay, color: iconColor)
|
||||
: AnimatedPlayPause(
|
||||
color: iconColor,
|
||||
playing: isPlaying,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
this.hideTimerDuration = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// A timer to hide the controls
|
||||
final hideTimer = useTimer(
|
||||
hideTimerDuration,
|
||||
() {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider).state;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
hideTimer.reset();
|
||||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When we mute, show the controls
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.mute),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
||||
(_, state) {
|
||||
// Show buffering
|
||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showControlsAndStartHideTimer,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !ref.watch(showControlsProvider),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying: state == VideoPlaybackState.playing,
|
||||
show: ref.watch(showControlsProvider),
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
mobile/lib/widgets/asset_viewer/description_input.dart
Normal file
107
mobile/lib/widgets/asset_viewer/description_input.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_description.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class DescriptionInput extends HookConsumerWidget {
|
||||
DescriptionInput({
|
||||
super.key,
|
||||
required this.asset,
|
||||
});
|
||||
|
||||
final Asset asset;
|
||||
final Logger _log = Logger('DescriptionInput');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final controller = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final isFocus = useState(false);
|
||||
final isTextEmpty = useState(controller.text.isEmpty);
|
||||
final descriptionProvider =
|
||||
ref.watch(assetDescriptionProvider(asset).notifier);
|
||||
final description = ref.watch(assetDescriptionProvider(asset));
|
||||
final owner = ref.watch(currentUserProvider);
|
||||
final hasError = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
controller.text = description;
|
||||
isTextEmpty.value = description.isEmpty;
|
||||
return null;
|
||||
},
|
||||
[description],
|
||||
);
|
||||
|
||||
submitDescription(String description) async {
|
||||
hasError.value = false;
|
||||
try {
|
||||
await descriptionProvider.setDescription(
|
||||
description,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
hasError.value = true;
|
||||
_log.severe("Error updating description", error, stack);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "description_input_submit_error".tr(),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget? suffixIcon;
|
||||
if (hasError.value) {
|
||||
suffixIcon = const Icon(Icons.warning_outlined);
|
||||
} else if (!isTextEmpty.value && isFocus.value) {
|
||||
suffixIcon = IconButton(
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
isTextEmpty.value = true;
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
splashRadius: 10,
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
enabled: owner?.isarId == asset.ownerId,
|
||||
focusNode: focusNode,
|
||||
onTap: () => isFocus.value = true,
|
||||
onChanged: (value) {
|
||||
isTextEmpty.value = false;
|
||||
},
|
||||
onTapOutside: (a) async {
|
||||
isFocus.value = false;
|
||||
focusNode.unfocus();
|
||||
|
||||
if (description != controller.text) {
|
||||
await submitDescription(controller.text);
|
||||
}
|
||||
},
|
||||
autofocus: false,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
controller: controller,
|
||||
style: context.textTheme.labelLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'description_input_hint_text'.tr(),
|
||||
border: InputBorder.none,
|
||||
hintStyle: context.textTheme.labelLarge?.copyWith(
|
||||
color: textColor.withOpacity(0.5),
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/description_input.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_detail.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_image_properties.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_location.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_people.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class ExifBottomSheet extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
|
||||
const ExifBottomSheet({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetWithExif = ref.watch(assetDetailProvider(asset));
|
||||
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
|
||||
// Format the date time with the timezone
|
||||
final (dt, timeZone) =
|
||||
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
|
||||
final date = DateFormat.yMMMEd().format(dt);
|
||||
final time = DateFormat.jm().format(dt);
|
||||
|
||||
String formattedDateTime = '$date • $time GMT${timeZone.formatAsOffset()}';
|
||||
|
||||
final dateWidget = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
formattedDateTime,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
onPressed: () => handleEditDateTime(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 50,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final horizontalPadding = constraints.maxWidth > 600 ? 24.0 : 16.0;
|
||||
if (constraints.maxWidth > 600) {
|
||||
// Two column
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
dateWidget,
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExifPeople(
|
||||
asset: asset,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ExifLocation(
|
||||
asset: asset,
|
||||
exifInfo: exifInfo,
|
||||
editLocation: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
formattedDateTime: formattedDateTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: ExifDetail(asset: asset, exifInfo: exifInfo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// One column
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
dateWidget,
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0),
|
||||
child: ExifLocation(
|
||||
asset: asset,
|
||||
exifInfo: exifInfo,
|
||||
editLocation: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
formattedDateTime: formattedDateTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ExifPeople(
|
||||
asset: asset,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_details",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color
|
||||
?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
ExifImageProperties(asset: asset),
|
||||
if (exifInfo?.make != null)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
Icons.camera,
|
||||
color: textColor.withAlpha(200),
|
||||
),
|
||||
title: Text(
|
||||
"${exifInfo!.make} ${exifInfo.model}",
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
subtitle: exifInfo.f != null ||
|
||||
exifInfo.exposureSeconds != null ||
|
||||
exifInfo.mm != null ||
|
||||
exifInfo.iso != null
|
||||
? Text(
|
||||
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
|
||||
style: context.textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart
Normal file
60
mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_image_properties.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
|
||||
class ExifDetail extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const ExifDetail({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.exifInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_details",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
ExifImageProperties(asset: asset),
|
||||
if (exifInfo?.make != null)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
Icons.camera,
|
||||
color: textColor.withAlpha(200),
|
||||
),
|
||||
title: Text(
|
||||
"${exifInfo?.make} ${exifInfo?.model}",
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
subtitle: exifInfo?.f != null ||
|
||||
exifInfo?.exposureSeconds != null ||
|
||||
exifInfo?.mm != null ||
|
||||
exifInfo?.iso != null
|
||||
? Text(
|
||||
"ƒ/${exifInfo?.fNumber} ${exifInfo?.exposureTime} ${exifInfo?.focalLength} mm ISO ${exifInfo?.iso ?? ''} ",
|
||||
style: context.textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
class ExifImageProperties extends StatelessWidget {
|
||||
final Asset asset;
|
||||
|
||||
const ExifImageProperties({
|
||||
super.key,
|
||||
required this.asset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
String resolution = asset.width != null && asset.height != null
|
||||
? "${asset.height} x ${asset.width} "
|
||||
: "";
|
||||
String fileSize = asset.exifInfo?.fileSize != null
|
||||
? formatBytes(asset.exifInfo!.fileSize!)
|
||||
: "";
|
||||
String text = resolution + fileSize;
|
||||
final imgSizeString = text.isNotEmpty ? text : null;
|
||||
|
||||
String? title;
|
||||
String? subtitle;
|
||||
|
||||
if (imgSizeString == null && asset.fileName.isNotEmpty) {
|
||||
// There is only filename
|
||||
title = asset.fileName;
|
||||
} else if (imgSizeString != null && asset.fileName.isNotEmpty) {
|
||||
// There is both filename and size information
|
||||
title = asset.fileName;
|
||||
subtitle = imgSizeString;
|
||||
} else if (imgSizeString != null && asset.fileName.isEmpty) {
|
||||
title = imgSizeString;
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
Icons.image,
|
||||
color: textColor.withAlpha(200),
|
||||
),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
title: Text(
|
||||
title,
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
subtitle: subtitle == null ? null : Text(subtitle),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart
Normal file
105
mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_map.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
|
||||
class ExifLocation extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
final void Function() editLocation;
|
||||
final String formattedDateTime;
|
||||
|
||||
const ExifLocation({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.exifInfo,
|
||||
required this.editLocation,
|
||||
required this.formattedDateTime,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates) {
|
||||
return asset.isRemote
|
||||
? ListTile(
|
||||
minLeadingWidth: 0,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
leading: const Icon(Icons.location_on),
|
||||
title: Text(
|
||||
"exif_bottom_sheet_location_add",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
onTap: editLocation,
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Location
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"exif_bottom_sheet_location",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
onPressed: editLocation,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
ExifMap(
|
||||
exifInfo: exifInfo!,
|
||||
formattedDateTime: formattedDateTime,
|
||||
markerId: asset.remoteId,
|
||||
),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: context.textTheme.labelLarge,
|
||||
children: [
|
||||
if (exifInfo != null && exifInfo?.city != null)
|
||||
TextSpan(
|
||||
text: exifInfo!.city,
|
||||
),
|
||||
if (exifInfo != null &&
|
||||
exifInfo?.city != null &&
|
||||
exifInfo?.state != null)
|
||||
const TextSpan(
|
||||
text: ", ",
|
||||
),
|
||||
if (exifInfo != null && exifInfo?.state != null)
|
||||
TextSpan(
|
||||
text: exifInfo!.state,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart
Normal file
94
mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExifMap extends StatelessWidget {
|
||||
final ExifInfo exifInfo;
|
||||
final String formattedDateTime;
|
||||
final String? markerId;
|
||||
|
||||
const ExifMap({
|
||||
super.key,
|
||||
required this.exifInfo,
|
||||
required this.formattedDateTime,
|
||||
this.markerId = 'marker',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCoordinates = exifInfo.hasCoordinates;
|
||||
Future<Uri?> createCoordinatesUri() async {
|
||||
if (!hasCoordinates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final double latitude = exifInfo.latitude!;
|
||||
final double longitude = exifInfo.longitude!;
|
||||
|
||||
const zoomLevel = 16;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
var params = {
|
||||
'll': '$latitude,$longitude',
|
||||
'q': formattedDateTime,
|
||||
'z': '$zoomLevel',
|
||||
};
|
||||
Uri uri = Uri.https('maps.apple.com', '/', params);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'openstreetmap.org',
|
||||
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
|
||||
fragment: 'map=$zoomLevel/$latitude/$longitude',
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return MapThumbnail(
|
||||
centre: LatLng(
|
||||
exifInfo.latitude ?? 0,
|
||||
exifInfo.longitude ?? 0,
|
||||
),
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
zoom: 12.0,
|
||||
assetMarkerRemoteId: markerId,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart
Normal file
97
mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
import 'package:immich_mobile/widgets/search/curated_people_row.dart';
|
||||
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
class ExifPeople extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const ExifPeople({super.key, required this.asset, this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final peopleProvider =
|
||||
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
||||
final people = ref
|
||||
.watch(assetPeopleNotifierProvider(asset))
|
||||
.value
|
||||
?.where((p) => !p.isHidden);
|
||||
final double imageSize = math.min(context.width / 3, 150);
|
||||
|
||||
showPersonNameEditModel(
|
||||
String personId,
|
||||
String personName,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return PersonNameEditForm(personId: personId, personName: personName);
|
||||
},
|
||||
).then((_) {
|
||||
// ensure the people list is up-to-date.
|
||||
peopleProvider.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (people?.isEmpty ?? true) {
|
||||
// Empty list or loading
|
||||
return Container();
|
||||
}
|
||||
|
||||
final curatedPeople = people
|
||||
?.map((p) => SearchCuratedContent(id: p.id, label: p.name))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
"exif_bottom_sheet_people",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: imageSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: CuratedPeopleRow(
|
||||
padding: padding,
|
||||
content: curatedPeople,
|
||||
onTap: (content, index) {
|
||||
context
|
||||
.pushRoute(
|
||||
PersonResultRoute(
|
||||
personId: content.id,
|
||||
personName: content.label,
|
||||
),
|
||||
)
|
||||
.then((_) => peopleProvider.refresh());
|
||||
},
|
||||
onNameTap: (person, index) => {
|
||||
showPersonNameEditModel(person.id, person.label),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
126
mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
Normal file
126
mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/trash.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
|
||||
import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class GalleryAppBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final void Function() showInfo;
|
||||
final void Function() onToggleMotionVideo;
|
||||
final bool isPlayingVideo;
|
||||
|
||||
const GalleryAppBar({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.showInfo,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingVideo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
|
||||
final isPartner = ref
|
||||
.watch(partnerSharedWithProvider)
|
||||
.map((e) => e.isarId)
|
||||
.contains(asset.ownerId);
|
||||
|
||||
toggleFavorite(Asset asset) =>
|
||||
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
||||
|
||||
handleActivities() {
|
||||
if (album != null && album.shared && album.remoteId != null) {
|
||||
context.pushRoute(const ActivitiesRoute());
|
||||
}
|
||||
}
|
||||
|
||||
handleRestore(Asset asset) async {
|
||||
final result = await ref.read(trashProvider.notifier).restoreAsset(asset);
|
||||
|
||||
if (result && context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'asset restored successfully',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleUpload(Asset asset) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return UploadDialog(
|
||||
onUpload: () {
|
||||
ref
|
||||
.read(manualUploadProvider.notifier)
|
||||
.uploadAssets(context, [asset]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
addToAlbum(Asset addToAlbumAsset) {
|
||||
showModalBottomSheet(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return AddToAlbumBottomSheet(
|
||||
assets: [addToAlbumAsset],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isOwner: isOwner,
|
||||
isPartner: isPartner,
|
||||
isPlayingMotionVideo: isPlayingVideo,
|
||||
asset: asset,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: toggleFavorite,
|
||||
onRestorePressed: () => handleRestore(asset),
|
||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||
onDownloadPressed: asset.isLocal
|
||||
? null
|
||||
: () =>
|
||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset,
|
||||
context,
|
||||
),
|
||||
onToggleMotionVideo: onToggleMotionVideo,
|
||||
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||
onActivitiesPressed: handleActivities,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// Provides the initialized video player controller
|
||||
/// If the asset is local, use the local file
|
||||
/// Otherwise, use a video player with a URL
|
||||
ChewieController useChewieController({
|
||||
required VideoPlayerController controller,
|
||||
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
bool showOptions = true,
|
||||
bool showControlsOnInitialize = false,
|
||||
bool autoPlay = true,
|
||||
bool allowFullScreen = false,
|
||||
bool allowedScreenSleep = false,
|
||||
bool showControls = true,
|
||||
Widget? customControls,
|
||||
Widget? placeholder,
|
||||
Duration hideControlsTimer = const Duration(seconds: 1),
|
||||
VoidCallback? onPlaying,
|
||||
VoidCallback? onPaused,
|
||||
VoidCallback? onVideoEnded,
|
||||
}) {
|
||||
return use(
|
||||
_ChewieControllerHook(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
showOptions: showOptions,
|
||||
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
|
||||
autoPlay: autoPlay,
|
||||
allowFullScreen: allowFullScreen,
|
||||
customControls: customControls,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
showControlsOnInitialize: showControlsOnInitialize,
|
||||
showControls: showControls,
|
||||
allowedScreenSleep: allowedScreenSleep,
|
||||
onPlaying: onPlaying,
|
||||
onPaused: onPaused,
|
||||
onVideoEnded: onVideoEnded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChewieControllerHook extends Hook<ChewieController> {
|
||||
final VideoPlayerController controller;
|
||||
final EdgeInsets controlsSafeAreaMinimum;
|
||||
final bool showOptions;
|
||||
final bool showControlsOnInitialize;
|
||||
final bool autoPlay;
|
||||
final bool allowFullScreen;
|
||||
final bool allowedScreenSleep;
|
||||
final bool showControls;
|
||||
final Widget? customControls;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final VoidCallback? onPlaying;
|
||||
final VoidCallback? onPaused;
|
||||
final VoidCallback? onVideoEnded;
|
||||
|
||||
const _ChewieControllerHook({
|
||||
required this.controller,
|
||||
this.controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
this.showOptions = true,
|
||||
this.showControlsOnInitialize = false,
|
||||
this.autoPlay = true,
|
||||
this.allowFullScreen = false,
|
||||
this.allowedScreenSleep = false,
|
||||
this.showControls = true,
|
||||
this.customControls,
|
||||
this.placeholder,
|
||||
this.hideControlsTimer = const Duration(seconds: 3),
|
||||
this.onPlaying,
|
||||
this.onPaused,
|
||||
this.onVideoEnded,
|
||||
});
|
||||
|
||||
@override
|
||||
createState() => _ChewieControllerHookState();
|
||||
}
|
||||
|
||||
class _ChewieControllerHookState
|
||||
extends HookState<ChewieController, _ChewieControllerHook> {
|
||||
late ChewieController chewieController = ChewieController(
|
||||
videoPlayerController: hook.controller,
|
||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||
showOptions: hook.showOptions,
|
||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||
autoPlay: hook.autoPlay,
|
||||
allowFullScreen: hook.allowFullScreen,
|
||||
allowedScreenSleep: hook.allowedScreenSleep,
|
||||
showControls: hook.showControls,
|
||||
customControls: hook.customControls,
|
||||
placeholder: hook.placeholder,
|
||||
hideControlsTimer: hook.hideControlsTimer,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
chewieController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
ChewieController build(BuildContext context) {
|
||||
return chewieController;
|
||||
}
|
||||
|
||||
/*
|
||||
/// Initializes the chewie controller and video player controller
|
||||
Future<void> _initialize() async {
|
||||
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
|
||||
// Use a local file for the video player controller
|
||||
final file = await hook.asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
videoPlayerController = VideoPlayerController.file(file);
|
||||
} else {
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
|
||||
final String videoUrl = hook.asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
|
||||
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
|
||||
|
||||
final url = Uri.parse(videoUrl);
|
||||
final accessToken = store.Store.get(StoreKey.accessToken);
|
||||
|
||||
videoPlayerController = VideoPlayerController.networkUrl(
|
||||
url,
|
||||
httpHeaders: {"x-immich-user-token": accessToken},
|
||||
);
|
||||
}
|
||||
|
||||
await videoPlayerController!.initialize();
|
||||
|
||||
chewieController = ChewieController(
|
||||
videoPlayerController: videoPlayerController!,
|
||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||
showOptions: hook.showOptions,
|
||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||
autoPlay: hook.autoPlay,
|
||||
allowFullScreen: hook.allowFullScreen,
|
||||
allowedScreenSleep: hook.allowedScreenSleep,
|
||||
showControls: hook.showControls,
|
||||
customControls: hook.customControls,
|
||||
placeholder: hook.placeholder,
|
||||
hideControlsTimer: hook.hideControlsTimer,
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
195
mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
Normal file
195
mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.onMoreInfoPressed,
|
||||
required this.onDownloadPressed,
|
||||
required this.onAddToAlbumPressed,
|
||||
required this.onRestorePressed,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingMotionVideo,
|
||||
required this.onFavorite,
|
||||
required this.onUploadPressed,
|
||||
required this.isOwner,
|
||||
required this.onActivitiesPressed,
|
||||
required this.isPartner,
|
||||
});
|
||||
|
||||
final Asset asset;
|
||||
final Function onMoreInfoPressed;
|
||||
final VoidCallback? onUploadPressed;
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onRestorePressed;
|
||||
final VoidCallback onActivitiesPressed;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isOwner;
|
||||
final bool isPartner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
final comments = album != null &&
|
||||
album.remoteId != null &&
|
||||
asset.remoteId != null
|
||||
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
|
||||
: 0;
|
||||
|
||||
Widget buildFavoriteButton(a) {
|
||||
return IconButton(
|
||||
onPressed: () => onFavorite(a),
|
||||
icon: Icon(
|
||||
a.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLivePhotoButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onToggleMotionVideo();
|
||||
},
|
||||
icon: isPlayingMotionVideo
|
||||
? Icon(
|
||||
Icons.motion_photos_pause_outlined,
|
||||
color: Colors.grey[200],
|
||||
)
|
||||
: Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildMoreInfoButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onMoreInfoPressed();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDownloadButton() {
|
||||
return IconButton(
|
||||
onPressed: onDownloadPressed,
|
||||
icon: Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAddToAlbumButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onAddToAlbumPressed();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildRestoreButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onRestorePressed();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.history_rounded,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildActivitiesButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onActivitiesPressed();
|
||||
},
|
||||
icon: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
if (comments != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Text(
|
||||
comments.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildUploadButton() {
|
||||
return IconButton(
|
||||
onPressed: onUploadPressed,
|
||||
icon: Icon(
|
||||
Icons.backup_outlined,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBackButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
context.popRoute();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 20.0,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
foregroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: buildBackButton(),
|
||||
actionsIconTheme: const IconThemeData(
|
||||
size: iconSize,
|
||||
),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
|
||||
buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||
buildAddToAlbumButton(),
|
||||
if (asset.isTrashed) buildRestoreButton(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
125
mobile/lib/widgets/asset_viewer/video_controls.dart
Normal file
125
mobile/lib/widgets/asset_viewer/video_controls.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
/// The video controls for the [videPlayerControlsProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
const VideoControls({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final duration =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
||||
final position =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: OrientationBuilder(
|
||||
builder: (context, orientation) => Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
||||
),
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: duration == Duration.zero
|
||||
? 0.0
|
||||
: min(
|
||||
position.inMicroseconds /
|
||||
duration.inMicroseconds *
|
||||
100,
|
||||
100,
|
||||
),
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withOpacity(0.75),
|
||||
onChanged: (position) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
||||
position;
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
ref.watch(
|
||||
videoPlayerControlsProvider.select((value) => value.mute),
|
||||
)
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
),
|
||||
onPressed: () => ref
|
||||
.read(videoPlayerControlsProvider.notifier)
|
||||
.toggleMute(),
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration position) {
|
||||
final ms = position.inMilliseconds;
|
||||
|
||||
int seconds = ms ~/ 1000;
|
||||
final int hours = seconds ~/ 3600;
|
||||
seconds = seconds % 3600;
|
||||
final minutes = seconds ~/ 60;
|
||||
seconds = seconds % 60;
|
||||
|
||||
final hoursString = hours >= 10
|
||||
? '$hours'
|
||||
: hours == 0
|
||||
? '00'
|
||||
: '0$hours';
|
||||
|
||||
final minutesString = minutes >= 10
|
||||
? '$minutes'
|
||||
: minutes == 0
|
||||
? '00'
|
||||
: '0$minutes';
|
||||
|
||||
final secondsString = seconds >= 10
|
||||
? '$seconds'
|
||||
: seconds == 0
|
||||
? '00'
|
||||
: '0$seconds';
|
||||
|
||||
final formattedTime =
|
||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
||||
|
||||
return formattedTime;
|
||||
}
|
||||
}
|
||||
45
mobile/lib/widgets/asset_viewer/video_player.dart
Normal file
45
mobile/lib/widgets/asset_viewer/video_player.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/hooks/chewiew_controller_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoPlayerViewer extends HookConsumerWidget {
|
||||
final VideoPlayerController controller;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final bool showControls;
|
||||
final bool showDownloadingIndicator;
|
||||
|
||||
const VideoPlayerViewer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.isMotionVideo,
|
||||
this.placeholder,
|
||||
required this.hideControlsTimer,
|
||||
required this.showControls,
|
||||
required this.showDownloadingIndicator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chewie = useChewieController(
|
||||
controller: controller,
|
||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
placeholder: SizedBox.expand(child: placeholder),
|
||||
customControls: CustomVideoPlayerControls(
|
||||
hideTimerDuration: hideControlsTimer,
|
||||
),
|
||||
showControls: showControls && !isMotionVideo,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
);
|
||||
|
||||
return Chewie(
|
||||
controller: chewie,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user