mirror of https://github.com/immich-app/immich.git
Merge branch 'main' into cool-app-bar
commit
b1baeb2f1e
File diff suppressed because one or more lines are too long
@ -0,0 +1,429 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
class NativeVideoViewer extends HookConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
|
final bool showControls;
|
||||||
|
final int playbackDelayFactor;
|
||||||
|
final Widget image;
|
||||||
|
|
||||||
|
const NativeVideoViewer({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
this.showControls = true,
|
||||||
|
this.playbackDelayFactor = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final controller = useState<NativeVideoPlayerController?>(null);
|
||||||
|
final lastVideoPosition = useRef(-1);
|
||||||
|
final isBuffering = useRef(false);
|
||||||
|
|
||||||
|
// Used to track whether the video should play when the app
|
||||||
|
// is brought back to the foreground
|
||||||
|
final shouldPlayOnForeground = useRef(true);
|
||||||
|
|
||||||
|
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
||||||
|
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
||||||
|
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
||||||
|
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
||||||
|
final currentAsset = useState(ref.read(currentAssetNotifier));
|
||||||
|
final isCurrent = currentAsset.value == asset;
|
||||||
|
|
||||||
|
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||||
|
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
||||||
|
|
||||||
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
|
Future<VideoSource?> createSource() async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (asset.hasLocal && asset.livePhotoVideoId == null) {
|
||||||
|
final id = asset is LocalAsset
|
||||||
|
? (asset as LocalAsset).id
|
||||||
|
: (asset as RemoteAsset).localId!;
|
||||||
|
final file = await const StorageRepository().getFileForAsset(id);
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
final remoteId = (asset as RemoteAsset).id;
|
||||||
|
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final isOriginalVideo =
|
||||||
|
ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
|
final String postfixUrl =
|
||||||
|
isOriginalVideo ? 'original' : 'video/playback';
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
|
||||||
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: videoUrl,
|
||||||
|
type: VideoSourceType.network,
|
||||||
|
headers: ApiService.getRequestHeaders(),
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
} catch (error) {
|
||||||
|
log.severe(
|
||||||
|
'Error creating video source for asset ${asset.name}: $error',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
|
final aspectRatio = useState<double?>(null);
|
||||||
|
useMemoized(
|
||||||
|
() async {
|
||||||
|
if (!context.mounted || aspectRatio.value != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
aspectRatio.value =
|
||||||
|
await ref.read(assetServiceProvider).getAspectRatio(asset);
|
||||||
|
} catch (error) {
|
||||||
|
log.severe(
|
||||||
|
'Error getting aspect ratio for asset ${asset.name}: $error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[asset.heroTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
void checkIfBuffering() {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||||
|
if ((isBuffering.value ||
|
||||||
|
videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||||
|
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||||
|
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer to mark videos as buffering if the position does not change
|
||||||
|
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||||
|
|
||||||
|
// When the position changes, seek to the position
|
||||||
|
// Debounce the seek to avoid seeking too often
|
||||||
|
// But also don't delay the seek too much to maintain visual feedback
|
||||||
|
final seekDebouncer = useDebouncer(
|
||||||
|
interval: const Duration(milliseconds: 100),
|
||||||
|
maxWaitTime: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
|
||||||
|
final newSeek = newControls.position ~/ 1;
|
||||||
|
if (oldSeek != newSeek || newControls.restarted) {
|
||||||
|
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||||
|
// Make sure the last seek is complete before pausing or playing
|
||||||
|
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||||
|
if (seekDebouncer.isActive) {
|
||||||
|
await seekDebouncer.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newControls.pause) {
|
||||||
|
await playerController.pause();
|
||||||
|
} else {
|
||||||
|
await playerController.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error pausing or playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void onPlaybackReady() async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !isCurrent || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback =
|
||||||
|
VideoPlaybackValue.fromNativeController(videoController);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
|
||||||
|
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await videoController.play();
|
||||||
|
await videoController.setVolume(0.9);
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackStatusChanged() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback =
|
||||||
|
VideoPlaybackValue.fromNativeController(videoController);
|
||||||
|
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||||
|
// Sync with the controls playing
|
||||||
|
WakelockPlus.enable();
|
||||||
|
} else {
|
||||||
|
// Sync with the controls pause
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).status =
|
||||||
|
videoPlayback.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackPositionChanged() {
|
||||||
|
// When seeking, these events sometimes move the slider to an older position
|
||||||
|
if (seekDebouncer.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = videoController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: playbackInfo.position);
|
||||||
|
|
||||||
|
// Check if the video is buffering
|
||||||
|
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||||
|
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||||
|
lastVideoPosition.value = playbackInfo.position;
|
||||||
|
} else {
|
||||||
|
isBuffering.value = false;
|
||||||
|
lastVideoPosition.value = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackEnded() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||||
|
!ref
|
||||||
|
.read(appSettingsServiceProvider)
|
||||||
|
.getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeListeners(NativeVideoPlayerController controller) {
|
||||||
|
controller.onPlaybackPositionChanged
|
||||||
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
controller.onPlaybackStatusChanged
|
||||||
|
.removeListener(onPlaybackStatusChanged);
|
||||||
|
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||||
|
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initController(NativeVideoPlayerController nc) async {
|
||||||
|
if (controller.value != null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
|
||||||
|
final source = await videoSource;
|
||||||
|
if (source == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
|
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||||
|
|
||||||
|
nc.loadVideoSource(source).catchError((error) {
|
||||||
|
log.severe('Error loading video source: $error');
|
||||||
|
});
|
||||||
|
final loopVideo = ref
|
||||||
|
.read(appSettingsServiceProvider)
|
||||||
|
.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
|
nc.setLoop(loopVideo);
|
||||||
|
|
||||||
|
controller.value = nc;
|
||||||
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen(currentAssetNotifier, (_, value) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController != null && value != asset) {
|
||||||
|
removeListeners(playerController);
|
||||||
|
}
|
||||||
|
|
||||||
|
final curAsset = currentAsset.value;
|
||||||
|
if (curAsset == asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
||||||
|
|
||||||
|
// No need to delay video playback when swiping from an image to a video
|
||||||
|
if (imageToVideo && Platform.isIOS) {
|
||||||
|
currentAsset.value = value;
|
||||||
|
onPlaybackReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the video playback to avoid a stutter in the swipe animation
|
||||||
|
// Note, in some circumstances a longer delay is needed (eg: memories),
|
||||||
|
// the playbackDelayFactor can be used for this
|
||||||
|
// This delay seems like a hacky way to resolve underlying bugs in video
|
||||||
|
// playback, but other resolutions failed thus far
|
||||||
|
Timer(
|
||||||
|
Platform.isIOS
|
||||||
|
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||||
|
: imageToVideo
|
||||||
|
? Duration(milliseconds: 200 * playbackDelayFactor)
|
||||||
|
: Duration(milliseconds: 400 * playbackDelayFactor), () {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAsset.value = value;
|
||||||
|
if (currentAsset.value == asset) {
|
||||||
|
onPlaybackReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
||||||
|
final timer = isVisible.value
|
||||||
|
? null
|
||||||
|
: Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() => isVisible.value = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () {
|
||||||
|
timer?.cancel();
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeListeners(playerController);
|
||||||
|
playerController.stop().catchError((error) {
|
||||||
|
log.fine('Error stopping video: $error');
|
||||||
|
});
|
||||||
|
|
||||||
|
WakelockPlus.disable();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useOnAppLifecycleStateChange((_, state) async {
|
||||||
|
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||||
|
controller.value?.play();
|
||||||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
|
final videoPlaying = await controller.value?.isPlaying();
|
||||||
|
if (videoPlaying ?? true) {
|
||||||
|
shouldPlayOnForeground.value = true;
|
||||||
|
controller.value?.pause();
|
||||||
|
} else {
|
||||||
|
shouldPlayOnForeground.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// This remains under the video to avoid flickering
|
||||||
|
// For motion videos, this is the image portion of the asset
|
||||||
|
Center(key: ValueKey(asset.heroTag), child: image),
|
||||||
|
if (aspectRatio.value != null && !isCasting)
|
||||||
|
Visibility.maintain(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
visible: isVisible.value,
|
||||||
|
child: Center(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
child: AspectRatio(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
aspectRatio: aspectRatio.value!,
|
||||||
|
child: isCurrent
|
||||||
|
? NativeVideoPlayerView(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
onViewReady: initController,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showControls) const Center(child: VideoViewerControls()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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';
|
||||||
|
|
||||||
|
class VideoViewerControls extends HookConsumerWidget {
|
||||||
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
|
const VideoViewerControls({
|
||||||
|
super.key,
|
||||||
|
this.hideTimerDuration = const Duration(seconds: 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final assetIsVideo = ref.watch(
|
||||||
|
currentAssetNotifier.select((asset) => asset != null && asset.isVideo),
|
||||||
|
);
|
||||||
|
bool showControls =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
final showBottomSheet =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||||
|
if (showBottomSheet) {
|
||||||
|
showControls = false;
|
||||||
|
}
|
||||||
|
final VideoPlaybackState state =
|
||||||
|
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
|
||||||
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
|
// A timer to hide the controls
|
||||||
|
final hideTimer = useTimer(
|
||||||
|
hideTimerDuration,
|
||||||
|
() {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final state = ref.read(videoPlaybackValueProvider).state;
|
||||||
|
|
||||||
|
// Do not hide on paused
|
||||||
|
if (state != VideoPlaybackState.paused &&
|
||||||
|
state != VideoPlaybackState.completed &&
|
||||||
|
assetIsVideo) {
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final showBuffering =
|
||||||
|
state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||||
|
|
||||||
|
/// Shows the controls and starts the timer to hide them
|
||||||
|
void showControlsAndStartHideTimer() {
|
||||||
|
hideTimer.reset();
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we change position, show or hide timer
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||||
|
(previous, next) {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
|
void togglePlay() {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
|
||||||
|
if (cast.isCasting) {
|
||||||
|
if (cast.castState == CastState.playing) {
|
||||||
|
ref.read(castProvider.notifier).pause();
|
||||||
|
} else if (cast.castState == CastState.paused) {
|
||||||
|
ref.read(castProvider.notifier).play();
|
||||||
|
} else if (cast.castState == CastState.idle) {
|
||||||
|
// resend the play command since its finished
|
||||||
|
final asset = ref.read(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == VideoPlaybackState.playing) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||||
|
} else {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: showControlsAndStartHideTimer,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: !showControls,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (showBuffering)
|
||||||
|
const Center(
|
||||||
|
child: DelayedLoadingIndicator(
|
||||||
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false),
|
||||||
|
child: CenterPlayButton(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
iconColor: Colors.white,
|
||||||
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
|
isPlaying: state == VideoPlaybackState.playing ||
|
||||||
|
(cast.isCasting && cast.castState == CastState.playing),
|
||||||
|
show: assetIsVideo && showControls,
|
||||||
|
onPressed: togglePlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_originalfilename_trigram","sql":"CREATE INDEX \\"idx_originalfilename_trigram\\" ON \\"assets\\" USING gin (f_unaccent(\\"originalFileName\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_originalfilename_trigram';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_local_date_time_month","sql":"CREATE INDEX \\"idx_local_date_time_month\\" ON \\"assets\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text));"}'::jsonb WHERE "name" = 'index_idx_local_date_time_month';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_local_date_time","sql":"CREATE INDEX \\"idx_local_date_time\\" ON \\"assets\\" (((\\"localDateTime\\" at time zone ''UTC'')::date));"}'::jsonb WHERE "name" = 'index_idx_local_date_time';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"UQ_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_library_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"libraryId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NOT NULL);"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_library_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"UQ_assets_owner_checksum","sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NULL);"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"IDX_activity_like","sql":"CREATE UNIQUE INDEX \\"IDX_activity_like\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true);"}'::jsonb WHERE "name" = 'index_IDX_activity_like';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"face_index","sql":"CREATE INDEX \\"face_index\\" ON \\"face_search\\" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16);"}'::jsonb WHERE "name" = 'index_face_index';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"IDX_geodata_gist_earthcoord","sql":"CREATE INDEX \\"IDX_geodata_gist_earthcoord\\" ON \\"geodata_places\\" (ll_to_earth_public(latitude, longitude));"}'::jsonb WHERE "name" = 'index_IDX_geodata_gist_earthcoord';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_name","sql":"CREATE INDEX \\"idx_geodata_places_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_admin2_name","sql":"CREATE INDEX \\"idx_geodata_places_admin2_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin2Name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin2_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_admin1_name","sql":"CREATE INDEX \\"idx_geodata_places_admin1_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin1Name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin1_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_alternate_names","sql":"CREATE INDEX \\"idx_geodata_places_alternate_names\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"alternateNames\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_alternate_names';`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_originalfilename_trigram\\" ON \\"assets\\" USING gin (f_unaccent(\\"originalFileName\\") gin_trgm_ops)","name":"idx_originalfilename_trigram","type":"index"}'::jsonb WHERE "name" = 'index_idx_originalfilename_trigram';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_local_date_time_month\\" ON \\"assets\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text))","name":"idx_local_date_time_month","type":"index"}'::jsonb WHERE "name" = 'index_idx_local_date_time_month';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_local_date_time\\" ON \\"assets\\" (((\\"localDateTime\\" at time zone ''UTC'')::date))","name":"idx_local_date_time","type":"index"}'::jsonb WHERE "name" = 'index_idx_local_date_time';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_library_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"libraryId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NOT NULL)","name":"UQ_assets_owner_library_checksum","type":"index"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_library_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NULL)","name":"UQ_assets_owner_checksum","type":"index"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"IDX_activity_like\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true)","name":"IDX_activity_like","type":"index"}'::jsonb WHERE "name" = 'index_IDX_activity_like';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"face_index\\" ON \\"face_search\\" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)","name":"face_index","type":"index"}'::jsonb WHERE "name" = 'index_face_index';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"IDX_geodata_gist_earthcoord\\" ON \\"geodata_places\\" (ll_to_earth_public(latitude, longitude))","name":"IDX_geodata_gist_earthcoord","type":"index"}'::jsonb WHERE "name" = 'index_IDX_geodata_gist_earthcoord';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops)","name":"idx_geodata_places_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_admin2_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin2Name\\") gin_trgm_ops)","name":"idx_geodata_places_admin2_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin2_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_admin1_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin1Name\\") gin_trgm_ops)","name":"idx_geodata_places_admin1_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin1_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_alternate_names\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"alternateNames\\") gin_trgm_ops)","name":"idx_geodata_places_alternate_names","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_alternate_names';`.execute(db);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue