Compare commits
18 Commits
v1.24.0_34
...
v1.25.0_35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83716ae1bc | ||
|
|
5cd4d2d158 | ||
|
|
13bb6d469b | ||
|
|
8e4c4c34e4 | ||
|
|
3125d04f32 | ||
|
|
c436c57cc9 | ||
|
|
7f9f825589 | ||
|
|
da9aed5c11 | ||
|
|
10ef3509dd | ||
|
|
3dc538f9e6 | ||
|
|
1e29ff322d | ||
|
|
9c30d58b10 | ||
|
|
013a0f8324 | ||
|
|
07b58f46f9 | ||
|
|
566e118a19 | ||
|
|
0e18c88534 | ||
|
|
068d06b9ee | ||
|
|
0cf7606ec9 |
@@ -6,6 +6,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run test:e2e
|
command: npm run test:e2e
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:16-bullseye-slim
|
# Build stage
|
||||||
|
FROM node:16-bullseye-slim as builder
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -15,3 +16,27 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# Prod stage
|
||||||
|
FROM node:16-bullseye-slim
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/src/app/dist \
|
||||||
|
&& mkdir -p /usr/src/app/node_modules \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y ffmpeg \
|
||||||
|
&& rm -rf /var/cache/apt/lists
|
||||||
|
|
||||||
|
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /usr/src/app/dist ./dist
|
||||||
|
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# CMD [ "node", "dist/main" ]
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# npm run typeorm migration:run
|
# npm run typeorm migration:run
|
||||||
npm run build && npm run start:prod
|
# npm run start:prod
|
||||||
|
node dist/main.js
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
immediate = true,
|
immediate = true,
|
||||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||||
|
initialDelayInMs = ONE_MINUTE,
|
||||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||||
}
|
}
|
||||||
engine?.destroy()
|
engine?.destroy()
|
||||||
@@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
immediate = true,
|
immediate = true,
|
||||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||||
|
initialDelayInMs = ONE_MINUTE,
|
||||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,22 +188,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String
|
||||||
showError(title, content)
|
val individualTag = args.get(2) as String?
|
||||||
|
showError(title, content, individualTag)
|
||||||
}
|
}
|
||||||
|
"clearErrorNotifications" -> clearErrorNotifications()
|
||||||
else -> r.notImplemented()
|
else -> r.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(title: String, content: String) {
|
private fun showError(title: String, content: String, individualTag: String?) {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setTicker(title)
|
.setTicker(title)
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setAutoCancel(true)
|
.setOnlyAlertOnce(true)
|
||||||
.build()
|
.build()
|
||||||
val notificationId = SystemClock.uptimeMillis() as Int
|
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||||
notificationManager.notify(notificationId, notification)
|
}
|
||||||
|
|
||||||
|
private fun clearErrorNotifications() {
|
||||||
|
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||||
@@ -212,14 +219,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(1, notification)
|
return ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||||
notificationManager.createNotificationChannel(foreground)
|
notificationManager.createNotificationChannel(foreground)
|
||||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
notificationManager.createNotificationChannel(error)
|
notificationManager.createNotificationChannel(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +243,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val NOTIFICATION_ERROR_ID = 2
|
||||||
|
private const val ONE_MINUTE: Long = 60000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues the `BackupWorker` to run when all constraints are met.
|
* Enqueues the `BackupWorker` to run when all constraints are met.
|
||||||
@@ -262,6 +272,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
keepExisting: Boolean = false,
|
keepExisting: Boolean = false,
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
requireUnmeteredNetwork: Boolean = false,
|
||||||
requireCharging: Boolean = false,
|
requireCharging: Boolean = false,
|
||||||
|
initialDelayInMs: Long = 0,
|
||||||
retries: Int = 0) {
|
retries: Int = 0) {
|
||||||
if (!isEnabled(context)) {
|
if (!isEnabled(context)) {
|
||||||
return
|
return
|
||||||
@@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||||
.setConstraints(constraints.build())
|
.setConstraints(constraints.build())
|
||||||
.setInputData(inputData)
|
.setInputData(inputData)
|
||||||
|
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
|
||||||
.setBackoffCriteria(
|
.setBackoffCriteria(
|
||||||
BackoffPolicy.EXPONENTIAL,
|
BackoffPolicy.EXPONENTIAL,
|
||||||
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
|
ONE_MINUTE,
|
||||||
TimeUnit.MILLISECONDS)
|
TimeUnit.MILLISECONDS)
|
||||||
.build()
|
.build()
|
||||||
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 34,
|
"android.injected.version.code" => 35,
|
||||||
"android.injected.version.name" => "1.24.0",
|
"android.injected.version.name" => "1.25.0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
* Feature - Customization options for asset grid
|
||||||
|
* Added pt-BR Translation: Translation into Portuguese Brazil
|
||||||
|
* Feature - Show notifications on background backup errors
|
||||||
|
* Optimization - Use CachedNetworkImage and separate cache for thumbnails on library page
|
||||||
@@ -5,17 +5,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000221">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000212">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="3.608039">
|
||||||
|
|
||||||
</testcase>
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.558064">
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -122,5 +122,5 @@
|
|||||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
||||||
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
||||||
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren liefert die beste Bildqualität, ist dafür aber langsamer beim Laden."
|
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
||||||
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
||||||
"backup_background_service_current_upload_notification": "Uploading {}",
|
"backup_background_service_current_upload_notification": "Uploading {}",
|
||||||
|
"backup_background_service_error_title": "Backup error",
|
||||||
|
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||||
|
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||||
"backup_controller_page_albums": "Backup Albums",
|
"backup_controller_page_albums": "Backup Albums",
|
||||||
"backup_controller_page_backup": "Backup",
|
"backup_controller_page_backup": "Backup",
|
||||||
"backup_controller_page_backup_selected": "Selected: ",
|
"backup_controller_page_backup_selected": "Selected: ",
|
||||||
@@ -135,5 +138,16 @@
|
|||||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
||||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||||
"theme_setting_three_stage_loading_subtitle": "The three-stage loading delivers the best quality image in exchange for a slower loading speed"
|
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||||
|
"asset_list_settings_title": "Photo Grid",
|
||||||
|
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||||
|
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
|
||||||
|
"setting_notifications_title": "Notifications",
|
||||||
|
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||||
|
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
|
||||||
|
"setting_notifications_notify_immediately": "immediately",
|
||||||
|
"setting_notifications_notify_minutes": "{} minutes",
|
||||||
|
"setting_notifications_notify_hours": "{} hours",
|
||||||
|
"setting_notifications_notify_never": "never"
|
||||||
}
|
}
|
||||||
|
|||||||
140
mobile/assets/i18n/pt-BR.json
Normal file
140
mobile/assets/i18n/pt-BR.json
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"album_info_card_backup_album_excluded": "EXCLUÍDO",
|
||||||
|
"album_info_card_backup_album_included": "INCLUÍDO",
|
||||||
|
"album_viewer_appbar_share_delete": "Excluir álbum",
|
||||||
|
"album_viewer_appbar_share_err_delete": "Falha ao excluir álbum",
|
||||||
|
"album_viewer_appbar_share_err_leave": "Falha ao sair do álbum",
|
||||||
|
"album_viewer_appbar_share_err_remove": "Há problemas ao remover recursos do álbum",
|
||||||
|
"album_viewer_appbar_share_err_title": "Falha ao alterar o título do álbum",
|
||||||
|
"album_viewer_appbar_share_leave": "Sair do álbum",
|
||||||
|
"album_viewer_appbar_share_remove": "Remover do álbum",
|
||||||
|
"album_viewer_page_share_add_users": "Adicionar usuários",
|
||||||
|
"backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})",
|
||||||
|
"backup_album_selection_page_albums_tap": "Toque para incluir, toque duas vezes para excluir",
|
||||||
|
"backup_album_selection_page_assets_scatter": "Os recursos podem se espalhar por vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.",
|
||||||
|
"backup_album_selection_page_select_albums": "Selecionar álbuns",
|
||||||
|
"backup_album_selection_page_selection_info": "Informações da Seleção",
|
||||||
|
"backup_album_selection_page_total_assets": "Total de recursos exclusivos",
|
||||||
|
"backup_all": "Todos",
|
||||||
|
"backup_background_service_default_notification": "Checking for new assets…",
|
||||||
|
"backup_background_service_disable_battery_optimizations": "Por favor, desabilite a otimização da bateria para Immich para habilitar o backup em segundo plano",
|
||||||
|
"backup_background_service_upload_failure_notification": "Falha ao carregar {}",
|
||||||
|
"backup_background_service_in_progress_notification": "Fazendo backup de seus ativos…",
|
||||||
|
"backup_background_service_current_upload_notification": "Enviando {}",
|
||||||
|
"backup_controller_page_albums": "Álbuns de backup",
|
||||||
|
"backup_controller_page_backup": "Backup",
|
||||||
|
"backup_controller_page_backup_selected": "Selecionado: ",
|
||||||
|
"backup_controller_page_backup_sub": "Backup de fotos e vídeos",
|
||||||
|
"backup_controller_page_background_description": "Ative o serviço em segundo plano para fazer backup automático de novos ativos sem precisar abrir o aplicativo",
|
||||||
|
"backup_controller_page_background_wifi": "Apenas em Wi-Fi",
|
||||||
|
"backup_controller_page_background_charging": "Apenas durante o carregamento",
|
||||||
|
"backup_controller_page_background_is_on": "O backup automático em segundo plano está ativado",
|
||||||
|
"backup_controller_page_background_is_off": "O backup automático em segundo plano está desativado",
|
||||||
|
"backup_controller_page_background_turn_on": "Ativar o serviço em segundo plano",
|
||||||
|
"backup_controller_page_background_turn_off": "Desativar o serviço em segundo plano",
|
||||||
|
"backup_controller_page_background_configure_error": "Falha ao configurar o serviço em segundo plano",
|
||||||
|
"backup_controller_page_cancel": "Cancelar",
|
||||||
|
"backup_controller_page_created": "Criado em: {}",
|
||||||
|
"backup_controller_page_desc_backup": "Ative o backup para carregar automaticamente novos ativos no servidor.",
|
||||||
|
"backup_controller_page_excluded": "Excluído: ",
|
||||||
|
"backup_controller_page_failed": "Falhou ({})",
|
||||||
|
"backup_controller_page_filename": "Nome do arquivo: {} [{}]",
|
||||||
|
"backup_controller_page_id": "ID: {}",
|
||||||
|
"backup_controller_page_info": "Informações de backup",
|
||||||
|
"backup_controller_page_none_selected": "Nenhum selecionado",
|
||||||
|
"backup_controller_page_remainder": "Restante",
|
||||||
|
"backup_controller_page_remainder_sub": "Fotos e vídeos restantes para fazer backup da seleção",
|
||||||
|
"backup_controller_page_select": "Selecionar",
|
||||||
|
"backup_controller_page_server_storage": "Armazenamento do servidor",
|
||||||
|
"backup_controller_page_start_backup": "Iniciar backup",
|
||||||
|
"backup_controller_page_status_off": "O backup está desativado",
|
||||||
|
"backup_controller_page_status_on": "O backup está ativado",
|
||||||
|
"backup_controller_page_storage_format": "{} de {} usado",
|
||||||
|
"backup_controller_page_to_backup": "Álbuns para backup",
|
||||||
|
"backup_controller_page_total": "Total",
|
||||||
|
"backup_controller_page_total_sub": "Todas as fotos e vídeos únicos dos álbuns selecionados",
|
||||||
|
"backup_controller_page_turn_off": "Desativar o backup",
|
||||||
|
"backup_controller_page_turn_on": "Ativar Backup",
|
||||||
|
"backup_controller_page_uploading_file_info": "Carregando informações do arquivo",
|
||||||
|
"backup_err_only_album": "Não é possível remover o único álbum",
|
||||||
|
"backup_info_card_assets": "ativos",
|
||||||
|
"control_bottom_app_bar_delete": "Excluir",
|
||||||
|
"create_shared_album_page_share": "Compartilhar",
|
||||||
|
"create_shared_album_page_create": "Criar",
|
||||||
|
"create_shared_album_page_share_add_assets": "ADICIONAR FOTOS",
|
||||||
|
"create_shared_album_page_share_select_photos": "Selecionar fotos",
|
||||||
|
"daily_title_text_date": "E, MMM dd",
|
||||||
|
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||||
|
"date_format": "E, LLL d, y • h:mm a",
|
||||||
|
"delete_dialog_alert": "Esses itens serão excluídos permanentemente do Immich e do seu dispositivo",
|
||||||
|
"delete_dialog_cancel": "Cancelar",
|
||||||
|
"delete_dialog_ok": "Excluir",
|
||||||
|
"delete_dialog_title": "Excluir permanentemente",
|
||||||
|
"exif_bottom_sheet_description": "Adicionar descrição...",
|
||||||
|
"exif_bottom_sheet_details": "DETALHES",
|
||||||
|
"exif_bottom_sheet_location": "LOCALIZAÇÃO",
|
||||||
|
"login_form_button_text": "Login",
|
||||||
|
"login_form_email_hint": "youremail@email.com",
|
||||||
|
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||||
|
"login_form_endpoint_url": "Server Endpoint URL",
|
||||||
|
"login_form_err_http": "Please specify http:// or https://",
|
||||||
|
"login_form_err_invalid_email": "E-mail inválido",
|
||||||
|
"login_form_err_leading_whitespace": "Leading whitespace",
|
||||||
|
"login_form_err_trailing_whitespace": "Trailing whitespace",
|
||||||
|
"login_form_failed_login": "Erro ao fazer login, verifique a url do servidor, e-mail e senha",
|
||||||
|
"login_form_label_email": "Email",
|
||||||
|
"login_form_label_password": "Password",
|
||||||
|
"login_form_password_hint": "password",
|
||||||
|
"login_form_save_login": "Permaneçer conectado",
|
||||||
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
|
"profile_drawer_client_server_up_to_date": "Cliente e Servidor estão atualizados",
|
||||||
|
"profile_drawer_sign_out": "Sair",
|
||||||
|
"profile_drawer_settings": "Configurações",
|
||||||
|
"search_bar_hint": "Procurar fotos",
|
||||||
|
"search_page_no_objects": "Nenhuma informação de objeto disponível",
|
||||||
|
"search_page_no_places": "Nenhuma informação de lugares disponível",
|
||||||
|
"search_page_places": "Lugares",
|
||||||
|
"search_page_things": "Coisas",
|
||||||
|
"search_result_page_new_search_hint": "Nova pesquisa",
|
||||||
|
"select_additional_user_for_sharing_page_suggestions": "Sugestões",
|
||||||
|
"select_user_for_sharing_page_err_album": "Falha ao criar álbum",
|
||||||
|
"select_user_for_sharing_page_share_suggestions": "Sugestões",
|
||||||
|
"share_add": "Adicionar",
|
||||||
|
"share_add_photos": "Adicionar fotos",
|
||||||
|
"share_add_title": "Adicione um título",
|
||||||
|
"share_create_album": "Criar álbum",
|
||||||
|
"share_invite": "Convidar para o álbum",
|
||||||
|
"sharing_page_album": "Álbuns compartilhados",
|
||||||
|
"sharing_page_description": "Crie álbuns compartilhados para compartilhar fotos e vídeos com pessoas em sua rede.",
|
||||||
|
"sharing_page_empty_list": "LISTA VAZIA",
|
||||||
|
"sharing_silver_appbar_create_shared_album": "Criar álbum compartilhado",
|
||||||
|
"sharing_silver_appbar_share_partner": "Compartilhe com o parceiro",
|
||||||
|
"tab_controller_nav_photos": "Fotos",
|
||||||
|
"tab_controller_nav_search": "Procurar",
|
||||||
|
"tab_controller_nav_sharing": "Compartilhamento",
|
||||||
|
"tab_controller_nav_library": "Biblioteca",
|
||||||
|
"version_announcement_overlay_ack": "Confirmar",
|
||||||
|
"version_announcement_overlay_release_notes": "notas de lançamento",
|
||||||
|
"version_announcement_overlay_text_1": "Oi amigo, há um novo lançamento de",
|
||||||
|
"version_announcement_overlay_text_2": "reserve um tempo para visitar o ",
|
||||||
|
"version_announcement_overlay_text_3": " e verifique se a configuração do docker-compose e do .env está atualizada para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo do servidor.",
|
||||||
|
"version_announcement_overlay_title": "Nova versão do servidor disponível \uD83C\uDF89",
|
||||||
|
"album_thumbnail_card_item": "1 item",
|
||||||
|
"album_thumbnail_card_items": "{} items",
|
||||||
|
"album_thumbnail_card_shared": " · Compartilhado",
|
||||||
|
"library_page_albums": "Albums",
|
||||||
|
"library_page_new_album": "Novo album",
|
||||||
|
"create_album_page_untitled": "Sem título",
|
||||||
|
"share_dialog_preparing": "Preparando...",
|
||||||
|
"control_bottom_app_bar_share": "Compartilhar",
|
||||||
|
"setting_pages_app_bar_settings": "Configurações",
|
||||||
|
"theme_setting_theme_title": "Tema",
|
||||||
|
"theme_setting_theme_subtitle": "Escolha a configuração de tema do app",
|
||||||
|
"theme_setting_system_theme_switch": "Automático (seguir a configuração do sistema)",
|
||||||
|
"theme_setting_dark_mode_switch": "Dark mode",
|
||||||
|
"theme_setting_image_viewer_quality_title": "Qualidade das imagens do visualizador",
|
||||||
|
"theme_setting_image_viewer_quality_subtitle": "Ajuste a qualidade de imagens detalhadas do visualizador",
|
||||||
|
"theme_setting_three_stage_loading_title": "Ative o carregamento em três estágios",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios oferece a imagem de melhor qualidade em troca de uma velocidade de carregamento mais lenta"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.24.0"
|
version_number: "1.25.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
|||||||
|
|
||||||
// User Setting Info
|
// User Setting Info
|
||||||
const String userSettingInfoBox = "immichUserSettingInfoBox";
|
const String userSettingInfoBox = "immichUserSettingInfoBox";
|
||||||
|
|
||||||
|
// Background backup Info
|
||||||
|
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
|
||||||
|
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
||||||
@@ -11,7 +11,8 @@ const List<Locale> locales = [
|
|||||||
Locale('fr', 'FR'),
|
Locale('fr', 'FR'),
|
||||||
Locale('it', 'IT'),
|
Locale('it', 'IT'),
|
||||||
Locale('ja', 'JP'),
|
Locale('ja', 'JP'),
|
||||||
Locale('pl', 'PL')
|
Locale('pl', 'PL'),
|
||||||
|
Locale('pt', 'PR')
|
||||||
];
|
];
|
||||||
|
|
||||||
const String translationsPath = 'assets/i18n';
|
const String translationsPath = 'assets/i18n';
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:transparent_image/transparent_image.dart';
|
|
||||||
|
|
||||||
class AlbumThumbnailCard extends StatelessWidget {
|
class AlbumThumbnailCard extends StatelessWidget {
|
||||||
const AlbumThumbnailCard({Key? key, required this.album}) : super(key: key);
|
const AlbumThumbnailCard({
|
||||||
|
Key? key,
|
||||||
|
required this.album,
|
||||||
|
required this.cacheService,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
final AlbumResponseDto album;
|
final AlbumResponseDto album;
|
||||||
|
final CacheService cacheService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -29,19 +38,19 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: FadeInImage(
|
child: CachedNetworkImage(
|
||||||
|
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
||||||
|
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: MemoryImage(kTransparentImage),
|
|
||||||
image: NetworkImage(
|
|
||||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${album.albumThumbnailAssetId}?format=JPEG',
|
|
||||||
headers: {
|
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
fadeOutDuration: const Duration(milliseconds: 200),
|
imageUrl:
|
||||||
|
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
|
||||||
|
httpHeaders: {
|
||||||
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
|
},
|
||||||
|
cacheKey: "${album.albumThumbnailAssetId}",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import 'package:openapi/api.dart';
|
|||||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
const AlbumViewerThumbnail({
|
const AlbumViewerThumbnail({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.assetList,
|
required this.assetList,
|
||||||
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -166,7 +168,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildThumbnailImage(),
|
_buildThumbnailImage(),
|
||||||
_buildAssetStoreLocationIcon(),
|
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
|
||||||
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
|
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
|
||||||
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
|
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
|
|||||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||||
@@ -186,12 +188,17 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
|
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
final bool showStorageIndicator =
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||||
|
|
||||||
if (albumInfo.assets.isNotEmpty) {
|
if (albumInfo.assets.isNotEmpty) {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.only(top: 10.0),
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount:
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
crossAxisSpacing: 5.0,
|
crossAxisSpacing: 5.0,
|
||||||
mainAxisSpacing: 5,
|
mainAxisSpacing: 5,
|
||||||
),
|
),
|
||||||
@@ -200,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
return AlbumViewerThumbnail(
|
return AlbumViewerThumbnail(
|
||||||
asset: albumInfo.assets[index],
|
asset: albumInfo.assets[index],
|
||||||
assetList: albumInfo.assets,
|
assetList: albumInfo.assets,
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: albumInfo.assetCount,
|
childCount: albumInfo.assetCount,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||||
|
|
||||||
class LibraryPage extends HookConsumerWidget {
|
class LibraryPage extends HookConsumerWidget {
|
||||||
const LibraryPage({Key? key}) : super(key: key);
|
const LibraryPage({Key? key}) : super(key: key);
|
||||||
@@ -13,6 +14,7 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider);
|
||||||
|
final cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -102,6 +104,7 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
_buildCreateAlbumButton(),
|
_buildCreateAlbumButton(),
|
||||||
for (var album in albums)
|
for (var album in albums)
|
||||||
AlbumThumbnailCard(
|
AlbumThumbnailCard(
|
||||||
|
cacheService: cacheService,
|
||||||
album: album,
|
album: album,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@@ -8,8 +9,9 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
|||||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:transparent_image/transparent_image.dart';
|
|
||||||
|
|
||||||
class SharingPage extends HookConsumerWidget {
|
class SharingPage extends HookConsumerWidget {
|
||||||
const SharingPage({Key? key}) : super(key: key);
|
const SharingPage({Key? key}) : super(key: key);
|
||||||
@@ -19,6 +21,7 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
|
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -32,29 +35,26 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId !=
|
final album = sharedAlbums[index];
|
||||||
null
|
|
||||||
? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}"
|
|
||||||
: "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60";
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: FadeInImage(
|
child: CachedNetworkImage(
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
|
memCacheHeight: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: MemoryImage(kTransparentImage),
|
cacheManager:
|
||||||
image: NetworkImage(
|
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
||||||
thumbnailUrl,
|
imageUrl: getAlbumThumbnailUrl(album),
|
||||||
headers: {
|
cacheKey: album.albumThumbnailAssetId,
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
httpHeaders: {
|
||||||
},
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
),
|
},
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
fadeOutDuration: const Duration(milliseconds: 200),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -16,6 +17,7 @@ import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dar
|
|||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ class BackgroundService {
|
|||||||
bool _hasLock = false;
|
bool _hasLock = false;
|
||||||
SendPort? _waitingIsolate;
|
SendPort? _waitingIsolate;
|
||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
|
||||||
bool get isForegroundInitialized {
|
bool get isForegroundInitialized {
|
||||||
return _isForegroundInitialized;
|
return _isForegroundInitialized;
|
||||||
@@ -140,8 +143,8 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the notification shown by the background service
|
/// Updates the notification shown by the background service
|
||||||
Future<bool> updateNotification({
|
Future<bool> _updateNotification({
|
||||||
String title = "Immich",
|
required String title,
|
||||||
String? content,
|
String? content,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
@@ -153,28 +156,44 @@ class BackgroundService {
|
|||||||
.invokeMethod('updateNotification', [title, content]);
|
.invokeMethod('updateNotification', [title, content]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[updateNotification] failed to communicate with plugin");
|
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||||
}
|
}
|
||||||
return Future.value(false);
|
return Future.value(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a new priority notification
|
/// Shows a new priority notification
|
||||||
Future<bool> showErrorNotification(
|
Future<bool> _showErrorNotification({
|
||||||
String title,
|
required String title,
|
||||||
String content,
|
String? content,
|
||||||
) async {
|
String? individualTag,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
|
||||||
|
return await _backgroundChannel
|
||||||
|
.invokeMethod('showError', [title, content, individualTag]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint("[_showErrorNotification] failed to communicate with plugin");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _clearErrorNotifications() async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (_isBackgroundInitialized) {
|
if (_isBackgroundInitialized) {
|
||||||
return await _backgroundChannel
|
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||||
.invokeMethod('showError', [title, content]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[showErrorNotification] failed to communicate with plugin");
|
debugPrint(
|
||||||
|
"[_clearErrorNotifications] failed to communicate with plugin");
|
||||||
}
|
}
|
||||||
return Future.value(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// await to ensure this thread (foreground or background) has exclusive access
|
/// await to ensure this thread (foreground or background) has exclusive access
|
||||||
@@ -278,7 +297,15 @@ class BackgroundService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await translationsLoaded;
|
await translationsLoaded;
|
||||||
return await _onAssetsChanged();
|
final bool ok = await _onAssetsChanged();
|
||||||
|
if (ok) {
|
||||||
|
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||||
|
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||||
|
null) {
|
||||||
|
Hive.box(backgroundBackupInfoBox)
|
||||||
|
.put(backupFailedSince, DateTime.now());
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
return false;
|
return false;
|
||||||
@@ -303,6 +330,8 @@ class BackgroundService {
|
|||||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
|
await Hive.openBox(userSettingInfoBox);
|
||||||
|
await Hive.openBox(backgroundBackupInfoBox);
|
||||||
|
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
@@ -313,23 +342,36 @@ class BackgroundService {
|
|||||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
if (backupAlbumInfo == null) {
|
if (backupAlbumInfo == null) {
|
||||||
|
_clearErrorNotifications();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<AssetEntity> toUpload =
|
List<AssetEntity> toUpload =
|
||||||
await backupService.getAssetsToBackup(backupAlbumInfo);
|
await backupService.buildUploadCandidates(backupAlbumInfo);
|
||||||
|
|
||||||
|
try {
|
||||||
|
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||||
|
} catch (e) {
|
||||||
|
_showErrorNotification(
|
||||||
|
title: "backup_background_service_error_title".tr(),
|
||||||
|
content: "backup_background_service_connection_failed_message".tr(),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toUpload.isEmpty) {
|
if (toUpload.isEmpty) {
|
||||||
|
_clearErrorNotifications();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,10 +385,16 @@ class BackgroundService {
|
|||||||
_onBackupError,
|
_onBackupError,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
_clearErrorNotifications();
|
||||||
await box.put(
|
await box.put(
|
||||||
backupInfoKey,
|
backupInfoKey,
|
||||||
backupAlbumInfo,
|
backupAlbumInfo,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
_showErrorNotification(
|
||||||
|
title: "backup_background_service_error_title".tr(),
|
||||||
|
content: "backup_background_service_backup_failed_message".tr(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
@@ -358,20 +406,48 @@ class BackgroundService {
|
|||||||
void _onProgress(int sent, int total) {}
|
void _onProgress(int sent, int total) {}
|
||||||
|
|
||||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||||
showErrorNotification(
|
_showErrorNotification(
|
||||||
"backup_background_service_upload_failure_notification"
|
title: "Upload failed",
|
||||||
|
content: "backup_background_service_upload_failure_notification"
|
||||||
.tr(args: [errorAssetInfo.fileName]),
|
.tr(args: [errorAssetInfo.fileName]),
|
||||||
errorAssetInfo.errorMessage,
|
individualTag: errorAssetInfo.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||||
updateNotification(
|
_updateNotification(
|
||||||
title: "backup_background_service_in_progress_notification".tr(),
|
title: "backup_background_service_in_progress_notification".tr(),
|
||||||
content: "backup_background_service_current_upload_notification"
|
content: "backup_background_service_current_upload_notification"
|
||||||
.tr(args: [currentUploadAsset.fileName]),
|
.tr(args: [currentUploadAsset.fileName]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isErrorGracePeriodExceeded() {
|
||||||
|
final int value = AppSettingsService()
|
||||||
|
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||||
|
if (value == 0) {
|
||||||
|
return true;
|
||||||
|
} else if (value == 5) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final DateTime? failedSince =
|
||||||
|
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
|
||||||
|
if (failedSince == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final Duration duration = DateTime.now().difference(failedSince);
|
||||||
|
if (value == 1) {
|
||||||
|
return duration > const Duration(minutes: 30);
|
||||||
|
} else if (value == 2) {
|
||||||
|
return duration > const Duration(hours: 2);
|
||||||
|
} else if (value == 3) {
|
||||||
|
return duration > const Duration(hours: 8);
|
||||||
|
} else if (value == 4) {
|
||||||
|
return duration > const Duration(hours: 24);
|
||||||
|
}
|
||||||
|
assert(false, "Invalid value");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||||
|
|||||||
@@ -41,21 +41,8 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all assets to backup from the backup info taking into account the
|
/// Returns all assets newer than the last successful backup per album
|
||||||
/// time of the last successfull backup per album
|
Future<List<AssetEntity>> buildUploadCandidates(
|
||||||
Future<List<AssetEntity>> getAssetsToBackup(
|
|
||||||
HiveBackupAlbums backupAlbumInfo,
|
|
||||||
) async {
|
|
||||||
final List<AssetEntity> candidates =
|
|
||||||
await _buildUploadCandidates(backupAlbumInfo);
|
|
||||||
|
|
||||||
final List<AssetEntity> toUpload = candidates.isEmpty
|
|
||||||
? []
|
|
||||||
: await _removeAlreadyUploadedAssets(candidates);
|
|
||||||
return toUpload;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<AssetEntity>> _buildUploadCandidates(
|
|
||||||
HiveBackupAlbums backupAlbums,
|
HiveBackupAlbums backupAlbums,
|
||||||
) async {
|
) async {
|
||||||
final filter = FilterOptionGroup(
|
final filter = FilterOptionGroup(
|
||||||
@@ -147,7 +134,8 @@ class BackupService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
|
/// Returns a new list of assets not yet uploaded
|
||||||
|
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||||
List<AssetEntity> candidates,
|
List<AssetEntity> candidates,
|
||||||
) async {
|
) async {
|
||||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import 'package:openapi/api.dart';
|
|||||||
class ImageGrid extends ConsumerWidget {
|
class ImageGrid extends ConsumerWidget {
|
||||||
final List<AssetResponseDto> assetGroup;
|
final List<AssetResponseDto> assetGroup;
|
||||||
final List<AssetResponseDto> sortedAssetGroup;
|
final List<AssetResponseDto> sortedAssetGroup;
|
||||||
|
final int tilesPerRow;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
ImageGrid({
|
ImageGrid({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.assetGroup,
|
required this.assetGroup,
|
||||||
required this.sortedAssetGroup,
|
required this.sortedAssetGroup,
|
||||||
|
this.tilesPerRow = 4,
|
||||||
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
List<AssetResponseDto> imageSortedList = [];
|
List<AssetResponseDto> imageSortedList = [];
|
||||||
@@ -19,8 +23,8 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return SliverGrid(
|
return SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 4,
|
crossAxisCount: tilesPerRow,
|
||||||
crossAxisSpacing: 5.0,
|
crossAxisSpacing: 5.0,
|
||||||
mainAxisSpacing: 5,
|
mainAxisSpacing: 5,
|
||||||
),
|
),
|
||||||
@@ -34,6 +38,7 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
ThumbnailImage(
|
ThumbnailImage(
|
||||||
asset: assetGroup[index],
|
asset: assetGroup[index],
|
||||||
assetList: sortedAssetGroup,
|
assetList: sortedAssetGroup,
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
),
|
),
|
||||||
if (assetType != AssetTypeEnum.IMAGE)
|
if (assetType != AssetTypeEnum.IMAGE)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import 'package:openapi/api.dart';
|
|||||||
class ThumbnailImage extends HookConsumerWidget {
|
class ThumbnailImage extends HookConsumerWidget {
|
||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
const ThumbnailImage({Key? key, required this.asset, required this.assetList})
|
const ThumbnailImage(
|
||||||
|
{Key? key,
|
||||||
|
required this.asset,
|
||||||
|
required this.assetList,
|
||||||
|
this.showStorageIndicator = true})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,7 +128,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
child: _buildSelectionIcon(asset),
|
child: _buildSelectionIcon(asset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (showStorageIndicator) Positioned(
|
||||||
right: 10,
|
right: 10,
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
|||||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
@@ -21,6 +23,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
ScrollController scrollController = useScrollController();
|
ScrollController scrollController = useScrollController();
|
||||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> imageGridGroup = [];
|
List<Widget> imageGridGroup = [];
|
||||||
@@ -86,6 +90,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
ImageGrid(
|
ImageGrid(
|
||||||
assetGroup: immichAssetList,
|
assetGroup: immichAssetList,
|
||||||
sortedAssetGroup: sortedAssetList,
|
sortedAssetGroup: sortedAssetList,
|
||||||
|
tilesPerRow: appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
|
showStorageIndicator: appSettingService.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
|
||||||
enum AppSettingsEnum {
|
enum AppSettingsEnum<T> {
|
||||||
threeStageLoading, // true, false,
|
threeStageLoading<bool>("threeStageLoading", false),
|
||||||
themeMode, // "light","dark","system"
|
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
||||||
|
tilesPerRow<int>("tilesPerRow", 4),
|
||||||
|
uploadErrorNotificationGracePeriod<int>(
|
||||||
|
"uploadErrorNotificationGracePeriod", 2),
|
||||||
|
storageIndicator<bool>("storageIndicator", true);
|
||||||
|
|
||||||
|
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
final String hiveKey;
|
||||||
|
final T defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSettingsService {
|
class AppSettingsService {
|
||||||
@@ -15,63 +22,26 @@ class AppSettingsService {
|
|||||||
hiveBox = Hive.box(userSettingInfoBox);
|
hiveBox = Hive.box(userSettingInfoBox);
|
||||||
}
|
}
|
||||||
|
|
||||||
T getSetting<T>(AppSettingsEnum settingType) {
|
T getSetting<T>(AppSettingsEnum<T> settingType) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
if (!hiveBox.containsKey(settingType.hiveKey)) {
|
||||||
|
return _setDefault(settingType);
|
||||||
if (!hiveBox.containsKey(settingKey)) {
|
|
||||||
T defaultSetting = _setDefaultSetting(settingType);
|
|
||||||
return defaultSetting;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = hiveBox.get(settingKey);
|
var result = hiveBox.get(settingType.hiveKey);
|
||||||
|
|
||||||
if (result is T) {
|
if (result is! T) {
|
||||||
return result;
|
return _setDefault(settingType);
|
||||||
} else {
|
|
||||||
debugPrint("Incorrect setting type");
|
|
||||||
throw TypeError();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSetting<T>(AppSettingsEnum settingType, T value) {
|
setSetting<T>(AppSettingsEnum<T> settingType, T value) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
hiveBox.put(settingType.hiveKey, value);
|
||||||
|
|
||||||
if (hiveBox.containsKey(settingKey)) {
|
|
||||||
var result = hiveBox.get(settingKey);
|
|
||||||
|
|
||||||
if (result is! T) {
|
|
||||||
debugPrint("Incorrect setting type");
|
|
||||||
throw TypeError();
|
|
||||||
}
|
|
||||||
|
|
||||||
hiveBox.put(settingKey, value);
|
|
||||||
} else {
|
|
||||||
hiveBox.put(settingKey, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_setDefaultSetting(AppSettingsEnum settingType) {
|
T _setDefault<T>(AppSettingsEnum<T> settingType) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
hiveBox.put(settingType.hiveKey, settingType.defaultValue);
|
||||||
|
return settingType.defaultValue;
|
||||||
// Default value of threeStageLoading is false
|
|
||||||
if (settingType == AppSettingsEnum.threeStageLoading) {
|
|
||||||
hiveBox.put(settingKey, false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default value of themeMode is "light"
|
|
||||||
if (settingType == AppSettingsEnum.themeMode) {
|
|
||||||
hiveBox.put(settingKey, "system");
|
|
||||||
return "system";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _settingHiveBoxKeyLookup(AppSettingsEnum settingType) {
|
|
||||||
switch (settingType) {
|
|
||||||
case AppSettingsEnum.threeStageLoading:
|
|
||||||
return 'threeStageLoading';
|
|
||||||
case AppSettingsEnum.themeMode:
|
|
||||||
return 'themeMode';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart';
|
||||||
|
import 'asset_list_tiles_per_row.dart';
|
||||||
|
|
||||||
|
class AssetListSettings extends StatelessWidget {
|
||||||
|
const AssetListSettings({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExpansionTile(
|
||||||
|
textColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
'asset_list_settings_title',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
subtitle: const Text(
|
||||||
|
'asset_list_settings_subtitle',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
children: const [
|
||||||
|
TilesPerRow(),
|
||||||
|
StorageIndicator(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
|
||||||
|
class StorageIndicator extends HookConsumerWidget {
|
||||||
|
const StorageIndicator({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final showStorageIndicator = useState(true);
|
||||||
|
|
||||||
|
void switchChanged(bool value) {
|
||||||
|
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
|
||||||
|
showStorageIndicator.value = value;
|
||||||
|
|
||||||
|
ref.invalidate(assetGroupByDateTimeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
showStorageIndicator.value = appSettingService.getSetting<bool>(AppSettingsEnum.storageIndicator);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return SwitchListTile.adaptive(
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
"theme_setting_asset_list_storage_indicator_title",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
onChanged: switchChanged,
|
||||||
|
value: showStorageIndicator.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
|
||||||
|
class TilesPerRow extends HookConsumerWidget {
|
||||||
|
const TilesPerRow({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final itemsValue = useState(4.0);
|
||||||
|
|
||||||
|
void sliderChanged(double value) {
|
||||||
|
appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt());
|
||||||
|
itemsValue.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sliderChangedEnd(double _) {
|
||||||
|
ref.invalidate(assetGroupByDateTimeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
int tilesPerRow =
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow);
|
||||||
|
itemsValue.value = tilesPerRow.toDouble();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(args: ["${itemsValue.value.toInt()}"]),
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
onChangeEnd: sliderChangedEnd,
|
||||||
|
onChanged: sliderChanged,
|
||||||
|
value: itemsValue.value,
|
||||||
|
min: 2,
|
||||||
|
max: 6,
|
||||||
|
divisions: 4,
|
||||||
|
label: "${itemsValue.value.toInt()}",
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ class ThreeStageLoading extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"theme_setting_three_stage_loading_title",
|
"theme_setting_three_stage_loading_title",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
|
||||||
|
class NotificationSetting extends HookConsumerWidget {
|
||||||
|
const NotificationSetting({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final sliderValue = useState(0.0);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
sliderValue.value = appSettingService
|
||||||
|
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
|
||||||
|
.toDouble();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
final String formattedValue = _formatSliderValue(sliderValue.value);
|
||||||
|
return ExpansionTile(
|
||||||
|
textColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
'setting_notifications_title',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
subtitle: const Text(
|
||||||
|
'setting_notifications_subtitle',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
isThreeLine: false,
|
||||||
|
dense: true,
|
||||||
|
title: const Text(
|
||||||
|
'setting_notifications_notify_failures_grace_period',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(args: [formattedValue]),
|
||||||
|
subtitle: Slider(
|
||||||
|
value: sliderValue.value,
|
||||||
|
onChanged: (double v) => sliderValue.value = v,
|
||||||
|
onChangeEnd: (double v) => appSettingService.setSetting(
|
||||||
|
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
|
||||||
|
max: 5.0,
|
||||||
|
divisions: 5,
|
||||||
|
label: formattedValue,
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSliderValue(double v) {
|
||||||
|
if (v == 0.0) {
|
||||||
|
return 'setting_notifications_notify_immediately'.tr();
|
||||||
|
} else if (v == 1.0) {
|
||||||
|
return 'setting_notifications_notify_minutes'.tr(args: const ['30']);
|
||||||
|
} else if (v == 2.0) {
|
||||||
|
return 'setting_notifications_notify_hours'.tr(args: const ['2']);
|
||||||
|
} else if (v == 3.0) {
|
||||||
|
return 'setting_notifications_notify_hours'.tr(args: const ['8']);
|
||||||
|
} else if (v == 4.0) {
|
||||||
|
return 'setting_notifications_notify_hours'.tr(args: const ['24']);
|
||||||
|
} else {
|
||||||
|
return 'setting_notifications_notify_never'.tr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||||
|
|
||||||
class SettingsPage extends HookConsumerWidget {
|
class SettingsPage extends HookConsumerWidget {
|
||||||
@@ -36,6 +40,8 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
tiles: [
|
tiles: [
|
||||||
const ImageViewerQualitySetting(),
|
const ImageViewerQualitySetting(),
|
||||||
const ThemeSetting(),
|
const ThemeSetting(),
|
||||||
|
const AssetListSettings(),
|
||||||
|
if (Platform.isAndroid) const NotificationSetting(),
|
||||||
],
|
],
|
||||||
).toList(),
|
).toList(),
|
||||||
],
|
],
|
||||||
|
|||||||
21
mobile/lib/shared/services/cache.service.dart
Normal file
21
mobile/lib/shared/services/cache.service.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
enum CacheType {
|
||||||
|
albumThumbnail,
|
||||||
|
sharedAlbumThumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cacheServiceProvider = Provider((_) => CacheService());
|
||||||
|
|
||||||
|
class CacheService {
|
||||||
|
|
||||||
|
BaseCacheManager getCache(CacheType type) {
|
||||||
|
return _getDefaultCache(type.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseCacheManager _getDefaultCache(String cacheName) {
|
||||||
|
return CacheManager(Config(cacheName));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,14 +3,31 @@ import 'package:openapi/api.dart';
|
|||||||
|
|
||||||
import '../constants/hive_box.dart';
|
import '../constants/hive_box.dart';
|
||||||
|
|
||||||
String getThumbnailUrl(final AssetResponseDto asset,
|
String getThumbnailUrl(
|
||||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
final AssetResponseDto asset, {
|
||||||
final box = Hive.box(userInfoBox);
|
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||||
|
}) {
|
||||||
|
return _getThumbnailUrl(asset.id, type: type);
|
||||||
|
}
|
||||||
|
|
||||||
return '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}?format=${type.value}';
|
String getAlbumThumbnailUrl(
|
||||||
|
final AlbumResponseDto album, {
|
||||||
|
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||||
|
}) {
|
||||||
|
if (album.albumThumbnailAssetId == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getImageUrl(final AssetResponseDto asset) {
|
String getImageUrl(final AssetResponseDto asset) {
|
||||||
final box = Hive.box(userInfoBox);
|
final box = Hive.box(userInfoBox);
|
||||||
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
|
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getThumbnailUrl(final String id,
|
||||||
|
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
||||||
|
final box = Hive.box(userInfoBox);
|
||||||
|
|
||||||
|
return '${box.get(serverEndpointKey)}/asset/thumbnail/${id}?format=${type.value}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.8"
|
version: "0.6.8"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.24.0+34
|
version: 1.25.0+35
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
@@ -43,6 +43,7 @@ dependencies:
|
|||||||
easy_localization: ^3.0.1
|
easy_localization: ^3.0.1
|
||||||
share_plus: ^4.0.10
|
share_plus: ^4.0.10
|
||||||
flutter_displaymode: ^0.4.0
|
flutter_displaymode: ^0.4.0
|
||||||
|
flutter_cache_manager: 3.3.0
|
||||||
|
|
||||||
path: ^1.8.1
|
path: ^1.8.1
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
FROM node:16-alpine3.14 as core
|
# Build stage
|
||||||
|
FROM node:16-alpine3.14 as builder
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
RUN apk add --update-cache build-base python3 libheif vips-dev
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# Prod stage
|
||||||
|
FROM node:16-alpine3.14
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY start-server.sh start-microservices.sh ./
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/src/app/dist \
|
||||||
|
&& apk add --no-cache libheif vips ffmpeg
|
||||||
|
|
||||||
|
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /usr/src/app/dist ./dist
|
||||||
|
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|||||||
@@ -202,7 +202,14 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
|
|
||||||
// TODO: No need to return boolean if using a singe delete query
|
// TODO: No need to return boolean if using a singe delete query
|
||||||
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
||||||
return this.get(album.id) as Promise<AlbumEntity>;
|
const retAlbum = await this.get(album.id) as AlbumEntity;
|
||||||
|
|
||||||
|
if (retAlbum?.assets?.length === 0) { // is empty album
|
||||||
|
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
|
||||||
|
retAlbum.albumThumbnailAssetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return retAlbum;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException('Some assets were not found in the album');
|
throw new BadRequestException('Some assets were not found in the album');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { randomUUID } from 'crypto';
|
|||||||
|
|
||||||
export const assetUploadOption: MulterOptions = {
|
export const assetUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter: (req: Request, file: any, cb: any) => {
|
||||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
|
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp)$/)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
|||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 24,
|
minor: 25,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
build: 0,
|
build: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export class AssetUploadedProcessor {
|
|||||||
|
|
||||||
// Extract video duration if uploaded from the web & CLI
|
// Extract video duration if uploaded from the web & CLI
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
await this.metadataExtractionQueue.add(videoMetadataExtractionProcessorName, { asset }, { jobId: randomUUID() });
|
await this.metadataExtractionQueue.add(
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
{ asset, fileName, fileSize },
|
||||||
|
{ jobId: randomUUID() }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,21 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
const res: [] = geoCodeInfo.body['features'];
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
let city = '';
|
||||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
let state = '';
|
||||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
let country = '';
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||||
|
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||||
|
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||||
|
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
newExif.city = city || null;
|
newExif.city = city || null;
|
||||||
newExif.state = state || null;
|
newExif.state = state || null;
|
||||||
@@ -114,9 +126,21 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
const res: [] = geoCodeInfo.body['features'];
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
let city = '';
|
||||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
let state = '';
|
||||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
let country = '';
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||||
|
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||||
|
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||||
|
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
||||||
}
|
}
|
||||||
@@ -166,33 +190,130 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
||||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||||
const { asset } = job.data;
|
const { asset, fileName } = job.data;
|
||||||
|
|
||||||
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
|
try {
|
||||||
if (!err) {
|
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||||
let durationString = asset.duration;
|
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||||
let createdAt = asset.createdAt;
|
if (err) return reject(err);
|
||||||
|
return resolve(data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let durationString = asset.duration;
|
||||||
|
let createdAt = asset.createdAt;
|
||||||
|
|
||||||
if (data.format.duration) {
|
if (data.format.duration) {
|
||||||
durationString = this.extractDuration(data.format.duration);
|
durationString = this.extractDuration(data.format.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoTags = data.format.tags;
|
const videoTags = data.format.tags;
|
||||||
if (videoTags) {
|
if (videoTags) {
|
||||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||||
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||||
} else if (videoTags['creation_time']) {
|
} else if (videoTags['creation_time']) {
|
||||||
createdAt = String(videoTags['creation_time']);
|
createdAt = String(videoTags['creation_time']);
|
||||||
} else {
|
|
||||||
createdAt = asset.createdAt;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
createdAt = asset.createdAt;
|
createdAt = asset.createdAt;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
createdAt = asset.createdAt;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const newExif = new ExifEntity();
|
||||||
|
newExif.assetId = asset.id;
|
||||||
|
newExif.description = '';
|
||||||
|
newExif.imageName = path.parse(fileName).name || null;
|
||||||
|
newExif.fileSizeInByte = data.format.size || null;
|
||||||
|
newExif.dateTimeOriginal = createdAt ? new Date(createdAt) : null;
|
||||||
|
newExif.modifyDate = null;
|
||||||
|
newExif.latitude = null;
|
||||||
|
newExif.longitude = null;
|
||||||
|
newExif.city = null;
|
||||||
|
newExif.state = null;
|
||||||
|
newExif.country = null;
|
||||||
|
newExif.fps = null;
|
||||||
|
|
||||||
|
if (videoTags && videoTags['location']) {
|
||||||
|
const location = videoTags['location'] as string;
|
||||||
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||||
|
const match = location.match(locationRegex);
|
||||||
|
|
||||||
|
if (match?.length === 3) {
|
||||||
|
newExif.latitude = parseFloat(match[1]);
|
||||||
|
newExif.longitude = parseFloat(match[2]);
|
||||||
|
}
|
||||||
|
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
||||||
|
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
||||||
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||||
|
const match = location.match(locationRegex);
|
||||||
|
|
||||||
|
if (match?.length === 4) {
|
||||||
|
newExif.latitude = parseFloat(match[1]);
|
||||||
|
newExif.longitude = parseFloat(match[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse GeoCoding
|
||||||
|
if (this.geocodingClient && newExif.longitude && newExif.latitude) {
|
||||||
|
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||||
|
.reverseGeocode({
|
||||||
|
query: [newExif.longitude, newExif.latitude],
|
||||||
|
types: ['country', 'region', 'place'],
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
|
||||||
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
|
let city = '';
|
||||||
|
let state = '';
|
||||||
|
let country = '';
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||||
|
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||||
|
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||||
|
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
newExif.city = city || null;
|
||||||
|
newExif.state = state || null;
|
||||||
|
newExif.country = country || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stream of data.streams) {
|
||||||
|
if (stream.codec_type === 'video') {
|
||||||
|
newExif.exifImageWidth = stream.width || null;
|
||||||
|
newExif.exifImageHeight = stream.height || null;
|
||||||
|
|
||||||
|
if (typeof stream.rotation === 'string') {
|
||||||
|
newExif.orientation = stream.rotation;
|
||||||
|
} else if (typeof stream.rotation === 'number') {
|
||||||
|
newExif.orientation = `${stream.rotation}`;
|
||||||
|
} else {
|
||||||
|
newExif.orientation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.r_frame_rate) {
|
||||||
|
let fpsParts = stream.r_frame_rate.split('/');
|
||||||
|
|
||||||
|
if (fpsParts.length === 2) {
|
||||||
|
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.exifRepository.save(newExif);
|
||||||
|
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
||||||
|
} catch (err) {
|
||||||
|
// do nothing
|
||||||
|
console.log('Error in video metadata extraction', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractDuration(duration: number) {
|
private extractDuration(duration: number) {
|
||||||
@@ -202,8 +323,6 @@ export class MetadataExtractionProcessor {
|
|||||||
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
||||||
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
||||||
|
|
||||||
return `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.000000`;
|
||||||
seconds < 10 ? '0' + seconds.toString() : seconds
|
|
||||||
}.000000`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,9 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'uuid' })
|
@Column({ type: 'uuid' })
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
/* General info */
|
||||||
make!: string | null;
|
@Column({ type: 'text', nullable: true, default: '' })
|
||||||
|
description!: string; // or caption
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
model!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
imageName!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'integer', nullable: true })
|
||||||
exifImageWidth!: number | null;
|
exifImageWidth!: number | null;
|
||||||
@@ -40,21 +35,6 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'timestamptz', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
modifyDate!: Date | null;
|
modifyDate!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
lensModel!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'float8', nullable: true })
|
|
||||||
fNumber!: number | null;
|
|
||||||
|
|
||||||
@Column({ type: 'float8', nullable: true })
|
|
||||||
focalLength!: number | null;
|
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
iso!: number | null;
|
|
||||||
|
|
||||||
@Column({ type: 'float', nullable: true })
|
|
||||||
exposureTime!: number | null;
|
|
||||||
|
|
||||||
@Column({ type: 'float', nullable: true })
|
@Column({ type: 'float', nullable: true })
|
||||||
latitude!: number | null;
|
latitude!: number | null;
|
||||||
|
|
||||||
@@ -70,9 +50,38 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
country!: string | null;
|
country!: string | null;
|
||||||
|
|
||||||
|
/* Image info */
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
make!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
model!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
imageName!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
lensModel!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float8', nullable: true })
|
||||||
|
fNumber!: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float8', nullable: true })
|
||||||
|
focalLength!: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true })
|
||||||
|
iso!: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
exposureTime!: number | null;
|
||||||
|
|
||||||
|
/* Video info */
|
||||||
|
@Column({ type: 'float8', nullable: true })
|
||||||
|
fps?: number | null;
|
||||||
|
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||||
asset?: ExifEntity;
|
asset?: AssetEntity;
|
||||||
|
|
||||||
@Index('exif_text_searchable', { synchronize: false })
|
@Index('exif_text_searchable', { synchronize: false })
|
||||||
@Column({
|
@Column({
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ export class SmartInfoEntity {
|
|||||||
|
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||||
asset?: SmartInfoEntity;
|
asset?: AssetEntity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddCaption1661011331242 implements MigrationInterface {
|
||||||
|
name = 'AddCaption1661011331242'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "description" text DEFAULT ''`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "fps" double precision`);
|
||||||
|
// await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "fps"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "description"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,6 +23,16 @@ export interface IVideoLengthExtractionProcessor {
|
|||||||
* The Asset entity that was saved in the database
|
* The Asset entity that was saved in the database
|
||||||
*/
|
*/
|
||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original file name
|
||||||
|
*/
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File size in byte
|
||||||
|
*/
|
||||||
|
fileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReverseGeocodingProcessor {
|
export interface IReverseGeocodingProcessor {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
|
|||||||
|
|
||||||
fileSelector.type = 'file';
|
fileSelector.type = 'file';
|
||||||
fileSelector.multiple = true;
|
fileSelector.multiple = true;
|
||||||
fileSelector.accept = 'image/*,video/*,.heic,.heif';
|
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng';
|
||||||
|
|
||||||
fileSelector.onchange = async (e: any) => {
|
fileSelector.onchange = async (e: any) => {
|
||||||
const files = Array.from<File>(e.target.files);
|
const files = Array.from<File>(e.target.files);
|
||||||
|
|||||||
Reference in New Issue
Block a user