Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-03-16 13:00:41 +01:00
539 changed files with 24437 additions and 10200 deletions
@@ -232,7 +232,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
}
Future<void> _performPause() async {
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress
@@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(backupProvider.notifier).cancelBackup();
}
} else {
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
}
_ref.read(websocketProvider.notifier).disconnect();
}
try {
await LogService.I.flush();
} catch (_) {}
return LogService.I.flush().catchError((_) {});
}
Future<void> handleAppDetached() async {
@@ -0,0 +1,154 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState {
final double backgroundOpacity;
final bool showingDetails;
final bool showingControls;
final bool isZoomed;
final bool showingOcr;
final BaseAsset? currentAsset;
final int stackIndex;
const AssetViewerState({
this.backgroundOpacity = 1.0,
this.showingDetails = false,
this.showingControls = true,
this.isZoomed = false,
this.showingOcr = false,
this.currentAsset,
this.stackIndex = 0,
});
AssetViewerState copyWith({
double? backgroundOpacity,
bool? showingDetails,
bool? showingControls,
bool? isZoomed,
bool? showingOcr,
BaseAsset? currentAsset,
int? stackIndex,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingDetails: showingDetails ?? this.showingDetails,
showingControls: showingControls ?? this.showingControls,
isZoomed: isZoomed ?? this.isZoomed,
showingOcr: showingOcr ?? this.showingOcr,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
}
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingDetails == showingDetails &&
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.showingOcr == showingOcr &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingDetails.hashCode ^
showingControls.hashCode ^
isZoomed.hashCode ^
showingOcr.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
ref.listen(_watchedCurrentAssetProvider, (_, next) {
final updated = next.valueOrNull;
if (updated != null) {
state = state.copyWith(currentAsset: updated);
}
});
return const AssetViewerState();
}
void reset() {
state = const AssetViewerState();
}
void setAsset(BaseAsset asset) {
if (asset == state.currentAsset) return;
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(double opacity) {
if (opacity == state.backgroundOpacity) {
return;
}
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
}
void setShowingDetails(bool showing) {
if (showing == state.showingDetails) {
return;
}
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
showing ? notifier.hold() : notifier.release();
}
}
void setControls(bool isShowing) {
if (isShowing == state.showingControls) {
return;
}
state = state.copyWith(showingControls: isShowing);
}
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
void setZoomed(bool isZoomed) {
if (isZoomed == state.isZoomed) {
return;
}
state = state.copyWith(isZoomed: isZoomed);
}
void setStackIndex(int index) {
if (index == state.stackIndex) {
return;
}
state = state.copyWith(stackIndex: index);
}
void toggleOcr() {
state = state.copyWith(showingOcr: !state.showingOcr);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return const Stream.empty();
return ref.read(assetServiceProvider).watchAsset(asset);
});
@@ -1,71 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
final Duration position;
final bool pause;
final bool restarted;
}
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
final Ref ref;
VideoPlaybackControls get value => state;
set value(VideoPlaybackControls value) {
state = value;
}
void reset() {
state = videoPlayerControlsDefault;
}
Duration get position => state.position;
bool get paused => state.pause;
set position(Duration value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(position: value, pause: state.pause);
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: true);
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: false);
}
void togglePlay() {
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
}
void restart() {
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
ref.read(videoPlaybackValueProvider.notifier).value = ref
.read(videoPlaybackValueProvider.notifier)
.value
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
}
}
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum VideoPlaybackStatus { paused, playing, buffering, completed }
class VideoPlayerState {
final Duration position;
final Duration duration;
final VideoPlaybackStatus status;
const VideoPlayerState({required this.position, required this.duration, required this.status});
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
return VideoPlayerState(
position: position ?? this.position,
duration: duration ?? this.duration,
status: status ?? this.status,
);
}
}
const _defaultState = VideoPlayerState(
position: Duration.zero,
duration: Duration.zero,
status: VideoPlaybackStatus.paused,
);
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
ref,
name,
) {
return VideoPlayerNotifier();
});
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
static final _log = Logger('VideoPlayerNotifier');
VideoPlayerNotifier() : super(_defaultState);
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
VideoPlaybackStatus? _holdStatus;
@override
void dispose() {
_bufferingTimer?.cancel();
_seekTimer?.cancel();
WakelockPlus.disable();
_controller = null;
super.dispose();
}
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
await _controller?.loadVideoSource(source);
} catch (e) {
_log.severe('Error loading video source: $e');
}
}
Future<void> pause() async {
if (_controller == null) return;
_bufferingTimer?.cancel();
try {
await _controller!.pause();
await _flushSeek();
} catch (e) {
_log.severe('Error pausing video: $e');
}
}
Future<void> play() async {
if (_controller == null) return;
try {
await _flushSeek();
await _controller!.play();
} catch (e) {
_log.severe('Error playing video: $e');
}
_startBufferingTimer();
}
Future<void> _flushSeek() async {
final timer = _seekTimer;
if (timer == null || !timer.isActive) return;
timer.cancel();
await _controller?.seekTo(state.position.inMilliseconds);
}
void seekTo(Duration position) {
if (_controller == null || state.position == position) return;
state = state.copyWith(position: position);
if (_seekTimer?.isActive ?? false) return;
_seekTimer = Timer(const Duration(milliseconds: 150), () {
_controller?.seekTo(state.position.inMilliseconds);
});
}
void toggle() {
_holdStatus = null;
switch (state.status) {
case VideoPlaybackStatus.paused:
play();
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
pause();
case VideoPlaybackStatus.completed:
restart();
}
}
/// Pauses playback and preserves the current status for later restoration.
void hold() {
if (_holdStatus != null) return;
_holdStatus = state.status;
pause();
}
/// Restores playback to the status before [hold] was called.
void release() {
final status = _holdStatus;
_holdStatus = null;
switch (status) {
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
play();
default:
}
}
Future<void> restart() async {
seekTo(Duration.zero);
await play();
}
Future<void> setVolume(double volume) async {
try {
await _controller?.setVolume(volume);
} catch (e) {
_log.severe('Error setting volume: $e');
}
}
Future<void> setLoop(bool loop) async {
try {
await _controller?.setLoop(loop);
} catch (e) {
_log.severe('Error setting loop: $e');
}
}
void onNativePlaybackReady() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
final videoInfo = _controller?.videoInfo;
if (playbackInfo == null || videoInfo == null) return;
state = state.copyWith(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
status: _mapStatus(playbackInfo.status),
);
}
void onNativePositionChanged() {
if (!mounted || (_seekTimer?.isActive ?? false)) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return;
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
state = state.copyWith(
position: position,
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
);
}
void onNativeStatusChanged() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final newStatus = _mapStatus(playbackInfo.status);
switch (newStatus) {
case VideoPlaybackStatus.playing:
WakelockPlus.enable();
_startBufferingTimer();
default:
onNativePlaybackEnded();
}
if (state.status != newStatus) state = state.copyWith(status: newStatus);
}
void onNativePlaybackEnded() {
WakelockPlus.disable();
_bufferingTimer?.cancel();
}
void _startBufferingTimer() {
_bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status != VideoPlaybackStatus.completed) {
state = state.copyWith(status: VideoPlaybackStatus.buffering);
}
});
}
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
PlaybackStatus.playing => VideoPlaybackStatus.playing,
PlaybackStatus.paused => VideoPlaybackStatus.paused,
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
};
}
@@ -1,88 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
class VideoPlaybackValue {
/// The current position of the video
final Duration position;
/// The total duration of the video
final Duration duration;
/// The current state of the video playback
final VideoPlaybackState state;
/// The volume of the video
final double volume;
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
final Ref ref;
VideoPlaybackValue get value => state;
set value(VideoPlaybackValue value) {
state = value;
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
}
set status(VideoPlaybackState value) {
if (state.state == value) return;
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
}
void reset() {
state = videoPlaybackValueDefault;
}
}
+2 -2
View File
@@ -123,7 +123,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken);
await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
@@ -144,7 +145,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
user = serverUser;
await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
await Store.put(StoreKey.accessToken, accessToken);
}
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
@@ -1,4 +1,5 @@
import 'package:cancellation_token_http/http.dart';
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress.
@@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier
AssetUploadProgressNotifier.new,
);
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);
final manualUploadCancelTokenProvider = StateProvider<Completer<void>?>((ref) => null);
@@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
@@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final FileMediaRepository _fileMediaRepository;
final BackupAlbumService _backupAlbumService;
final Ref ref;
Completer<void>? _cancelToken;
///
/// UI INTERACTION
@@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_cancelToken?.complete();
_cancelToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
@@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
_cancelToken!,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress,
@@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
_cancelToken?.complete();
_cancelToken = null;
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
@@ -109,7 +108,6 @@ class DriftBackupState {
final BackupError error;
final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
final Map<String, double> iCloudDownloadProgress;
@@ -121,7 +119,6 @@ class DriftBackupState {
required this.isSyncing,
this.error = BackupError.none,
required this.uploadItems,
this.cancelToken,
this.iCloudDownloadProgress = const {},
});
@@ -133,7 +130,6 @@ class DriftBackupState {
bool? isSyncing,
BackupError? error,
Map<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Map<String, double>? iCloudDownloadProgress,
}) {
return DriftBackupState(
@@ -144,7 +140,6 @@ class DriftBackupState {
isSyncing: isSyncing ?? this.isSyncing,
error: error ?? this.error,
uploadItems: uploadItems ?? this.uploadItems,
cancelToken: cancelToken ?? this.cancelToken,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
);
}
@@ -153,7 +148,7 @@ class DriftBackupState {
@override
String toString() {
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)';
}
@override
@@ -168,8 +163,7 @@ class DriftBackupState {
other.isSyncing == isSyncing &&
other.error == error &&
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
mapEquals(other.uploadItems, uploadItems) &&
other.cancelToken == cancelToken;
mapEquals(other.uploadItems, uploadItems);
}
@override
@@ -181,7 +175,6 @@ class DriftBackupState {
isSyncing.hashCode ^
error.hashCode ^
uploadItems.hashCode ^
cancelToken.hashCode ^
iCloudDownloadProgress.hashCode;
}
}
@@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
final ForegroundUploadService _foregroundUploadService;
final BackgroundUploadService _backgroundUploadService;
final UploadSpeedManager _uploadSpeedManager;
Completer<void>? _cancelToken;
final _logger = Logger("DriftBackupNotifier");
@@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
void updateError(BackupError error) async {
void updateError(BackupError error) {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
@@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(error: error);
}
void updateSyncing(bool isSyncing) async {
void updateSyncing(bool isSyncing) {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startForegroundBackup(String userId) async {
Future<void> startForegroundBackup(String userId) {
// Cancel any existing backup before starting a new one
if (state.cancelToken != null) {
await stopForegroundBackup();
if (_cancelToken != null) {
stopForegroundBackup();
}
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
state = state.copyWith(cancelToken: cancelToken);
_cancelToken = Completer<void>();
return _foregroundUploadService.uploadCandidates(
userId,
cancelToken,
_cancelToken!,
callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
@@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
Future<void> stopForegroundBackup() async {
state.cancelToken?.cancel();
void stopForegroundBackup() {
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
}
void _handleICloudProgress(String localAssetId, double progress) {
@@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
if (state.cancelToken == null) {
if (_cancelToken == null) {
return;
}
@@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
}
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) async {
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
return [];
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
@@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final BackupService _backupService;
final BackupAlbumService _backupAlbumService;
final Ref ref;
Completer<void>? _cancelToken;
ManualUploadNotifier(
this._localNotificationService,
@@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
fileName: '...',
fileType: '...',
),
cancelToken: CancellationToken(),
);
// Reset Error List
ref.watch(errorBackupListProvider.notifier).empty();
@@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state = state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
_cancelToken?.complete();
_cancelToken = Completer<void>();
final bool ok = await ref
.read(backupServiceProvider)
.backupAsset(
uploadAssets,
state.cancelToken,
_cancelToken!,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onProgress,
@@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
);
// User cancelled upload
if (!ok && state.cancelToken.isCancelled) {
if (!ok && _cancelToken == null) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_cancelled".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) {
} else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"failed".tr(),
@@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
_cancelToken?.complete();
_cancelToken = null;
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
}
+10
View File
@@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
return discovered;
}
void toggle() {
switch (state.castState) {
case CastState.playing:
pause();
case CastState.paused:
play();
default:
}
}
void play() {
_gCastService.play();
}
-40
View File
@@ -1,40 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
import 'package:immich_mobile/services/api.service.dart';
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader
class ImageLoader {
static Future<ui.Codec> loadImageFromCache(
String uri, {
required CacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
}) async {
final headers = ApiService.getRequestHeaders();
final stream = cache.getFileStream(uri, withProgress: chunkEvents != null, headers: headers);
await for (final result in stream) {
if (result is DownloadProgress) {
// We are downloading the file, so update the [chunkEvents]
chunkEvents?.add(
ImageChunkEvent(cumulativeBytesLoaded: result.downloaded, expectedTotalBytes: result.totalSize),
);
} else if (result is FileInfo) {
// We have the file
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
return decode(buffer);
}
}
// If we get here, the image failed to load from the cache stream
throw const ImageLoadingException('Could not load image from stream');
}
}
@@ -1,25 +0,0 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class RemoteImageCacheManager extends CacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._() : super(_config);
}
class RemoteThumbnailCacheManager extends CacheManager {
static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
factory RemoteThumbnailCacheManager() {
return _instance;
}
RemoteThumbnailCacheManager._() : super(_config);
}
@@ -1,13 +0,0 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider]
class ThumbnailImageCacheManager extends CacheManager {
static const key = 'thumbnailImageCacheKey';
static final ThumbnailImageCacheManager _instance = ThumbnailImageCacheManager._();
factory ThumbnailImageCacheManager() {
return _instance;
}
ThumbnailImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)));
}
@@ -2,15 +2,14 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -123,7 +122,7 @@ class ActionNotifier extends Notifier<void> {
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
BaseAsset asset => {asset},
null => const {},
},
@@ -307,7 +306,10 @@ class ActionNotifier extends Notifier<void> {
// does not update the currentAsset which means
// the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) {
ref.invalidate(currentAssetExifProvider);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
ref.invalidate(assetExifProvider(currentAsset));
}
}
return ActionResult(count: ids.length, success: true);
@@ -409,7 +411,6 @@ class ActionNotifier extends Notifier<void> {
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
@@ -453,7 +454,7 @@ class ActionNotifier extends Notifier<void> {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = CancellationToken();
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
// Initialize progress for all assets
@@ -464,7 +465,7 @@ class ActionNotifier extends Notifier<void> {
try {
await _foregroundUploadService.uploadManual(
assetsToUpload,
cancelToken,
cancelToken: cancelToken,
callbacks: UploadCallbacks(
onProgress: (localAssetId, filename, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
@@ -1,52 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
KeepAliveLink? _keepAliveLink;
StreamSubscription<BaseAsset?>? _assetSubscription;
@override
BaseAsset? build() => null;
void setAsset(BaseAsset asset) {
_keepAliveLink?.close();
_assetSubscription?.cancel();
state = asset;
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
if (updatedAsset != null) {
state = updatedAsset;
}
});
_keepAliveLink = ref.keepAlive();
}
void dispose() {
_keepAliveLink?.close();
_assetSubscription?.cancel();
}
}
class ScopedAssetNotifier extends CurrentAssetNotifier {
final BaseAsset _asset;
ScopedAssetNotifier(this._asset);
@override
BaseAsset? build() {
setAsset(_asset);
return _asset;
}
}
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
final currentAsset = ref.watch(currentAssetNotifier);
if (currentAsset == null) {
return null;
}
return ref.watch(assetServiceProvider).getExif(currentAsset);
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
return ref.watch(assetServiceProvider).getExif(asset);
});
@@ -1,11 +1,10 @@
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'db.provider.dart';
final driftMemoryRepositoryProvider = Provider<DriftMemoryRepository>(
(ref) => DriftMemoryRepository(ref.watch(driftProvider)),
);
@@ -6,11 +6,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart';
class RemoteAlbumState {
final List<RemoteAlbum> albums;
@@ -24,10 +24,12 @@ class MultiSelectState {
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets,
Set<BaseAsset>? lockedSelectionAssets,
+5 -14
View File
@@ -1,18 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) {
try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final headers = ApiService.getRequestHeaders();
if (endpoint.userInfo.isNotEmpty) {
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
}
dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
@@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
OptionBuilder()
.setPath("${endpoint.path}/socket.io")
.setTransports(['websocket'])
.setWebSocketConnector(NetworkRepository.createWebSocket)
.enableReconnection()
.enableForceNew()
.enableForceNewConnection()
.enableAutoConnect()
.setExtraHeaders(headers)
.build(),
);
@@ -160,11 +154,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear();
var socket = state.socket?.disconnect();
if (socket?.disconnected == true) {
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
}
state.socket?.dispose();
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
}
void stopListenToEvent(String eventName) {
@@ -319,7 +310,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data]));
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data));
}
void _processBatchedAssetUploadReady() {