mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
refactor: native_video_player
This commit is contained in:
parent
6f3ceb58b8
commit
5ebac69647
9 changed files with 247 additions and 231 deletions
|
@ -13,7 +13,6 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
|||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
|
@ -353,10 +352,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
),
|
||||
);
|
||||
} else {
|
||||
final useNativePlayer =
|
||||
a.isLocal && a.livePhotoVideoId == null;
|
||||
debugPrint("asset.isLocal ${asset.isLocal}");
|
||||
debugPrint("build video player $useNativePlayer");
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
|
@ -371,24 +366,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: useNativePlayer
|
||||
? NativeVideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
)
|
||||
: VideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
isMotionVideo: a.livePhotoVideoId != null,
|
||||
loopVideo: shouldLoopVideo.value,
|
||||
placeholder: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
child: NativeVideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
isMotionVideo: a.livePhotoVideoId != null,
|
||||
loopVideo: shouldLoopVideo.value,
|
||||
placeholder: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,158 +1,226 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/native_video_player_controller_provider.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controller_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/video_player.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class NativeVideoViewerPage extends ConsumerStatefulWidget {
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final bool showControls;
|
||||
final Duration hideControlsTimer;
|
||||
final bool loopVideo;
|
||||
|
||||
const NativeVideoViewerPage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.isMotionVideo = false,
|
||||
this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.loopVideo = false,
|
||||
});
|
||||
|
||||
@override
|
||||
NativeVideoViewerPageState createState() => NativeVideoViewerPageState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
|
||||
class NativeVideoViewerPageState extends ConsumerState<NativeVideoViewerPage> {
|
||||
NativeVideoPlayerController? _controller;
|
||||
Future<VideoSource> createSource(Asset asset) async {
|
||||
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
||||
final file = await asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
return await VideoSource.init(
|
||||
path: file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
} else {
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final String videoUrl = asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||
|
||||
bool isAutoplayEnabled = false;
|
||||
bool isPlaybackLoopEnabled = false;
|
||||
|
||||
double videoWidth = 0;
|
||||
double videoHeight = 0;
|
||||
|
||||
Future<void> _initController(NativeVideoPlayerController controller) async {
|
||||
_controller = controller;
|
||||
|
||||
_controller?. //
|
||||
onPlaybackStatusChanged
|
||||
.addListener(_onPlaybackStatusChanged);
|
||||
_controller?. //
|
||||
onPlaybackPositionChanged
|
||||
.addListener(_onPlaybackPositionChanged);
|
||||
_controller?. //
|
||||
onPlaybackSpeedChanged
|
||||
.addListener(_onPlaybackSpeedChanged);
|
||||
_controller?. //
|
||||
onVolumeChanged
|
||||
.addListener(_onPlaybackVolumeChanged);
|
||||
_controller?. //
|
||||
onPlaybackReady
|
||||
.addListener(_onPlaybackReady);
|
||||
_controller?. //
|
||||
onPlaybackEnded
|
||||
.addListener(_onPlaybackEnded);
|
||||
|
||||
await _loadVideoSource();
|
||||
}
|
||||
|
||||
Future<void> _loadVideoSource() async {
|
||||
final videoSource = await _createVideoSource();
|
||||
await _controller?.loadVideoSource(videoSource);
|
||||
}
|
||||
|
||||
Future<VideoSource> _createVideoSource() async {
|
||||
final file = await widget.asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
return await VideoSource.init(
|
||||
path: videoUrl,
|
||||
type: VideoSourceType.network,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await VideoSource.init(
|
||||
path: file.path,
|
||||
type: VideoSourceType.file,
|
||||
// When the volume changes, set the volume
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||
(_, mute) {
|
||||
if (mute) {
|
||||
controller.value?.setVolume(0.0);
|
||||
} else {
|
||||
controller.value?.setVolume(0.7);
|
||||
}
|
||||
});
|
||||
|
||||
// When the position changes, seek to the position
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||
(_, position) {
|
||||
if (controller.value == null) {
|
||||
// No seeeking if there is no video
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the position to seek to
|
||||
final Duration seek = asset.duration * (position / 100.0);
|
||||
controller.value?.seekTo(seek.inSeconds);
|
||||
});
|
||||
|
||||
// When the custom video controls paus or plays
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
|
||||
(_, pause) {
|
||||
if (pause) {
|
||||
controller.value?.pause();
|
||||
} else {
|
||||
controller.value?.play();
|
||||
}
|
||||
});
|
||||
|
||||
void updateVideoPlayback() {
|
||||
if (controller.value == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback =
|
||||
VideoPlaybackValue.fromNativeController(controller.value!);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
final state = videoPlayback.state;
|
||||
|
||||
// Enable the WakeLock while the video is playing
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackReady() {
|
||||
controller.value?.play();
|
||||
}
|
||||
|
||||
void onPlaybackPositionChanged() {
|
||||
updateVideoPlayback();
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
if (loopVideo) {
|
||||
controller.value?.play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initController(NativeVideoPlayerController nc) async {
|
||||
if (controller.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.value = nc;
|
||||
|
||||
controller.value?.onPlaybackPositionChanged
|
||||
.addListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackStatusChanged
|
||||
.addListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackReady.addListener(onPlaybackReady);
|
||||
controller.value?.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
final videoSource = await createSource(asset);
|
||||
controller.value?.loadVideoSource(videoSource);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
Future.microtask(
|
||||
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
|
||||
);
|
||||
|
||||
if (isMotionVideo) {
|
||||
// ignore: prefer-extracting-callbacks
|
||||
Future.microtask(() {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () {
|
||||
controller.value?.onPlaybackPositionChanged
|
||||
.removeListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackStatusChanged
|
||||
.removeListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackReady.removeListener(onPlaybackReady);
|
||||
controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?. //
|
||||
onPlaybackStatusChanged
|
||||
.removeListener(_onPlaybackStatusChanged);
|
||||
_controller?. //
|
||||
onPlaybackPositionChanged
|
||||
.removeListener(_onPlaybackPositionChanged);
|
||||
_controller?. //
|
||||
onPlaybackSpeedChanged
|
||||
.removeListener(_onPlaybackSpeedChanged);
|
||||
_controller?. //
|
||||
onVolumeChanged
|
||||
.removeListener(_onPlaybackVolumeChanged);
|
||||
_controller?. //
|
||||
onPlaybackReady
|
||||
.removeListener(_onPlaybackReady);
|
||||
_controller?. //
|
||||
onPlaybackEnded
|
||||
.removeListener(_onPlaybackEnded);
|
||||
_controller = null;
|
||||
super.dispose();
|
||||
}
|
||||
void updatePlayback(VideoPlaybackValue value) =>
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = value;
|
||||
|
||||
void _onPlaybackReady() {
|
||||
final videoInfo = _controller?.videoInfo;
|
||||
if (videoInfo != null) {
|
||||
videoWidth = videoInfo.width.toDouble();
|
||||
videoHeight = videoInfo.height.toDouble();
|
||||
}
|
||||
setState(() {});
|
||||
_controller?.play();
|
||||
}
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
void _onPlaybackStatusChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPlaybackPositionChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPlaybackSpeedChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPlaybackVolumeChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPlaybackEnded() {
|
||||
if (isPlaybackLoopEnabled) {
|
||||
_controller?.play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
onPopInvoked: (pop) {},
|
||||
child: SizedBox(
|
||||
height: videoHeight,
|
||||
width: videoWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: NativeVideoPlayerView(
|
||||
onViewReady: _initController,
|
||||
return SizedBox(
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
updatePlayback(VideoPlaybackValue.uninitialized()),
|
||||
child: SizedBox(
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: (asset.width ?? 1) / (asset.height ?? 1),
|
||||
child: NativeVideoPlayerView(
|
||||
onViewReady: initController,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showControls)
|
||||
Center(
|
||||
child: CustomVideoPlayerControls(
|
||||
hideTimerDuration: hideControlsTimer,
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: controller.value == null,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (placeholder != null) placeholder!,
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// final Asset asset;
|
||||
// final Widget? placeholder;
|
||||
// final Duration hideControlsTimer;
|
||||
// final bool showControls;
|
||||
// final bool showDownloadingIndicator;
|
||||
// final bool loopVideo;
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
final nativePlayerControllerProvider =
|
||||
StateProvider((ref) => NativeVideoPlayerController(0));
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
enum VideoPlaybackState {
|
||||
|
@ -29,6 +30,32 @@ class VideoPlaybackValue {
|
|||
required this.volume,
|
||||
});
|
||||
|
||||
factory VideoPlaybackValue.fromNativeController(
|
||||
NativeVideoPlayerController controller,
|
||||
) {
|
||||
final playbackInfo = controller.playbackInfo;
|
||||
final videoInfo = controller.videoInfo;
|
||||
late VideoPlaybackState s;
|
||||
if (playbackInfo?.status == null) {
|
||||
s = VideoPlaybackState.initializing;
|
||||
} else if (playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
(playbackInfo?.positionFraction == 1 ||
|
||||
playbackInfo?.positionFraction == 0)) {
|
||||
s = VideoPlaybackState.completed;
|
||||
} else if (playbackInfo?.status == PlaybackStatus.playing) {
|
||||
s = VideoPlaybackState.playing;
|
||||
} else {
|
||||
s = VideoPlaybackState.paused;
|
||||
}
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: Duration(seconds: playbackInfo?.position ?? 0),
|
||||
duration: Duration(seconds: videoInfo?.duration ?? 0),
|
||||
state: s,
|
||||
volume: playbackInfo?.volume ?? 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
||||
final video = controller?.value;
|
||||
late VideoPlaybackState s;
|
||||
|
|
|
@ -4,9 +4,9 @@ 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/utils/hooks/timer_hook.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/utils/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
|
@ -86,12 +86,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
onTap: () =>
|
||||
ref.read(showControlsProvider.notifier).show = false,
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class NativeVideoPlayer extends HookConsumerWidget {
|
||||
final VideoPlayerController controller;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final bool showControls;
|
||||
final bool showDownloadingIndicator;
|
||||
final bool loopVideo;
|
||||
final Asset asset;
|
||||
|
||||
const NativeVideoPlayer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.isMotionVideo,
|
||||
this.placeholder,
|
||||
required this.hideControlsTimer,
|
||||
required this.showControls,
|
||||
required this.showDownloadingIndicator,
|
||||
required this.loopVideo,
|
||||
required this.asset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return NativeVideoPlayerView(
|
||||
onViewReady: (controller) async {
|
||||
try {
|
||||
String path = '';
|
||||
VideoSourceType type = VideoSourceType.file;
|
||||
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
||||
// Use a local file for the video player controller
|
||||
final file = await asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
path = file.path;
|
||||
type = VideoSourceType.file;
|
||||
|
||||
final videoSource = await VideoSource.init(
|
||||
path: path,
|
||||
type: type,
|
||||
);
|
||||
|
||||
await controller.loadVideoSource(videoSource);
|
||||
await controller.play();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
await controller.setVolume(0.5);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error loading video: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,9 +2,9 @@ import 'dart:ui';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||
|
||||
|
@ -68,10 +68,9 @@ class MemoryCard extends StatelessWidget {
|
|||
} else {
|
||||
return Hero(
|
||||
tag: 'memory-${asset.id}',
|
||||
child: VideoViewerPage(
|
||||
child: NativeVideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
showDownloadingIndicator: false,
|
||||
placeholder: SizedBox.expand(
|
||||
child: ImmichImage(
|
||||
asset,
|
||||
|
|
|
@ -1027,10 +1027,11 @@ packages:
|
|||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: native_video_player
|
||||
sha256: "8df92df138c13ebf9df6b30525f9c4198534705fd450a98da14856d3a0e48cd4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: "feat/headers"
|
||||
resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed"
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
nested:
|
||||
dependency: transitive
|
||||
|
|
|
@ -64,7 +64,10 @@ dependencies:
|
|||
async: ^2.11.0
|
||||
dynamic_color: ^1.7.0 #package to apply system theme
|
||||
background_downloader: ^8.5.5
|
||||
native_video_player: ^1.3.1
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: feat/headers
|
||||
|
||||
#image editing packages
|
||||
crop_image: ^1.0.13
|
||||
|
|
Loading…
Reference in a new issue