Compare commits

..

22 Commits

Author SHA1 Message Date
Alex Tran 736b968eab merge main 2024-04-22 06:52:30 -05:00
Alex Tran d5d8426bda Merge branch 'main' of github.com:immich-app/immich into web/automation-ui 2024-04-22 06:51:57 -05:00
Alex be4a783845 fix(web): wrong month on timeline scrollbar cursor (#8996)
* fix(web): wrong month on timeline scrollbar cursor

* revert unnesessary change
2024-04-22 06:22:59 -05:00
Mert c30cd3b378 chore: test more formats in e2e (#9001) 2024-04-22 01:35:27 -04:00
TruongSinh Tran-Nguyen 0d3cc28f45 feat(web): support 360 video (equirectangular) (#8762)
* [web]: support 360 video

* lint

* lint

* fix typing

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-04-21 19:14:54 +00:00
Conner Hnatiuk f004487be0 fix(web): trash page now auto refreshes (#8978)
* fix(web): the trash page now auto refreshes when restore all or empty trash is clicked. Also shows number of assets affected.

* formatting
2024-04-21 14:07:17 -05:00
clementdelestre 21231d53a5 feat(mobile): add i18n in multiselect-grid and update translation (en and fr) (#8993)
* add i18n in multiselect grid (en-fr)

* add FR translations from (haptic feedback)

* revert settings
2024-04-21 13:26:19 -05:00
Daniel Dietzler a99862120d feat: mobile label for renovate pull requests (#8991)
mobile lable for renovate pull requests
2024-04-21 13:11:03 -05:00
shenlong 776023b149 dep(mobile): upgrade gradle (#8409)
* dep(mobile): upgrade gradle

* chore(deps): update kotlin & guava

* build: change java version and flutter test version

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2024-04-20 23:07:32 -05:00
martin 7d4187962a feat(web): new look option for slideshow (#8924)
feat: new look option for slideshow
2024-04-20 23:06:49 -05:00
Jason Rasmussen a93534fc3c refactor(server): session interface types (#8977) 2024-04-20 23:45:55 -04:00
Alex cef84f6ced chore(mobile): override appbundle on PlayStore before getting released (#8960) 2024-04-20 19:56:03 -05:00
Alex The Bot a2180a467d Version v1.102.3 2024-04-20 20:17:39 +00:00
Jason Rasmussen 1e3dceea4d fix(server): session refresh (#8974) 2024-04-20 15:15:25 -05:00
Mert fd4514711f feat(server): enable AV1 encoding for NVENC (#8959)
allow av1 for nvenc
2024-04-20 14:52:50 -04:00
Alex 2dd7c13b88 Revert "feat(android) Check server is reachable before starting background backup (#8594)" (#8958)
This reverts commit 71b6d8b569.
2024-04-20 12:15:26 -05:00
Alex 40931b5668 chore: post release tasks 2024-04-20 11:15:41 -05:00
Alex 7e92ef9428 Merge branch 'main' of github.com:immich-app/immich into web/automation-ui 2024-04-17 11:13:14 +02:00
Alex 372fae20d9 light theme 2024-04-08 07:37:25 +02:00
Alex 16543a233b basic ui complete 2024-04-07 20:37:26 -05:00
Alex 73b961f5fa colors 2024-04-07 19:38:23 -05:00
Alex e0d15c96f1 feat(web): workflow automation ui 2024-04-07 19:18:49 -05:00
60 changed files with 544 additions and 233 deletions
+5 -5
View File
@@ -37,15 +37,15 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: "zulu" distribution: 'zulu'
java-version: "11.0.21+9" java-version: '17'
cache: "gradle" cache: 'gradle'
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: 'stable'
flutter-version: "3.19.3" flutter-version: '3.19.3'
cache: true cache: true
- name: Create the Keystore - name: Create the Keystore
+1 -1
View File
@@ -208,7 +208,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: 'stable' channel: 'stable'
flutter-version: '3.16.9' flutter-version: '3.19.3'
- name: Run tests - name: Run tests
working-directory: ./mobile working-directory: ./mobile
run: flutter test -j 1 run: flutter test -j 1
+1 -1
View File
@@ -47,7 +47,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.102.2", "version": "1.102.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
@@ -81,7 +81,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.102.2", "version": "1.102.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+106
View File
@@ -572,6 +572,22 @@ describe('/asset', () => {
} }
const tests = [ const tests = [
{
input: 'formats/avif/8bit-sRGB.avif',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.avif',
resized: true,
exifInfo: {
description: '',
exifImageHeight: 1080,
exifImageWidth: 1617,
fileSizeInByte: 862_424,
latitude: null,
longitude: null,
},
},
},
{ {
input: 'formats/jpg/el_torcal_rocks.jpg', input: 'formats/jpg/el_torcal_rocks.jpg',
expected: { expected: {
@@ -596,6 +612,22 @@ describe('/asset', () => {
}, },
}, },
}, },
{
input: 'formats/jxl/8bit-sRGB.jxl',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.jxl',
resized: true,
exifInfo: {
description: '',
exifImageHeight: 1080,
exifImageWidth: 1440,
fileSizeInByte: 1_780_777,
latitude: null,
longitude: null,
},
},
},
{ {
input: 'formats/heic/IMG_2682.heic', input: 'formats/heic/IMG_2682.heic',
expected: { expected: {
@@ -681,6 +713,80 @@ describe('/asset', () => {
}, },
}, },
}, },
{
input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '4_3.rw2',
resized: true,
fileCreatedAt: '2018-05-10T08:42:37.842Z',
exifInfo: {
make: 'Panasonic',
model: 'DMC-GH4',
exifImageHeight: 3456,
exifImageWidth: 4608,
exposureTime: '1/100',
fNumber: 3.2,
focalLength: 35,
iso: 400,
fileSizeInByte: 19_587_072,
dateTimeOriginal: '2018-05-10T08:42:37.842Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '12bit-compressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-09-27T10:51:44.000Z',
exifInfo: {
make: 'SONY',
model: 'ILCE-6300',
exifImageHeight: 4024,
exifImageWidth: 6048,
exposureTime: '1/320',
fNumber: 8,
focalLength: 97,
iso: 100,
lensModel: 'E PZ 18-105mm F4 G OSS',
fileSizeInByte: 25_001_984,
dateTimeOriginal: '2016-09-27T10:51:44.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '14bit-uncompressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-01-08T15:08:01.000Z',
exifInfo: {
make: 'SONY',
model: 'ILCE-7M2',
exifImageHeight: 4024,
exifImageWidth: 6048,
exposureTime: '1.3',
fNumber: 22,
focalLength: 25,
iso: 100,
lensModel: 'E 25mm F2',
fileSizeInByte: 49_512_448,
dateTimeOriginal: '2016-01-08T15:08:01.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
]; ];
for (const { input, expected } of tests) { for (const { input, expected } of tests) {
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.102.2" version = "1.102.3"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
+17 -17
View File
@@ -1,14 +1,14 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "kotlin-kapt"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader -> localPropertiesFile.withInputStream { localProperties.load(it) }
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
} }
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
@@ -21,18 +21,12 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties') def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
} }
android { android {
compileSdkVersion 34 compileSdkVersion 34
@@ -50,7 +44,6 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich" applicationId "app.alextran.immich"
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 33 targetSdkVersion 33
@@ -88,9 +81,16 @@ flutter {
} }
dependencies { dependencies {
def kotlin_version = '1.9.23'
def kotlin_coroutines_version = '1.8.0'
def work_version = '2.9.0'
def concurrent_version = '1.1.0'
def guava_version = '33.1.0-android'
def glide_version = '4.16.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
implementation "com.google.guava:guava:$guava_version" implementation "com.google.guava:guava:$guava_version"
implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:glide:$glide_version"
@@ -52,7 +52,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String)
.apply() .apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true) result.success(true)
@@ -11,8 +11,8 @@ import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
@@ -30,16 +30,6 @@ import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation import io.flutter.view.FlutterCallbackInformation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.URL
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -52,6 +42,7 @@ import java.util.concurrent.TimeUnit
*/ */
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -61,80 +52,35 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private var notificationDetailBuilder: NotificationCompat.Builder? = null private var notificationDetailBuilder: NotificationCompat.Builder? = null
private var fgFuture: ListenableFuture<Void>? = null private var fgFuture: ListenableFuture<Void>? = null
private val job = Job()
private lateinit var completer: CallbackToFutureAdapter.Completer<Result>
private val resolvableFuture = CallbackToFutureAdapter.getFuture { completer ->
this.completer = completer
null
}
init {
resolvableFuture.addListener(
Runnable {
if (resolvableFuture.isCancelled) {
job.cancel()
}
},
taskExecutor.serialTaskExecutor
)
}
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork") Log.d(TAG, "startWork")
val ctx = applicationContext val ctx = applicationContext
val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
prefs.getString(SHARED_PREF_SERVER_URL, null) if (!flutterLoader.initialized()) {
?.takeIf { it.isNotEmpty() } flutterLoader.startInitialization(ctx)
?.let { serverUrl -> doCoroutineWork(serverUrl) }
?: doWork()
return resolvableFuture
}
/**
* This function is used to check if server URL is reachable before starting the backup work.
* Check must be done in a background to avoid blocking the main thread.
*/
private fun doCoroutineWork(serverUrl : String) {
CoroutineScope(Dispatchers.Default + job).launch {
val isReachable = isUrlReachableHttp(serverUrl)
withContext(Dispatchers.Main) {
if (isReachable) {
doWork()
} else {
// Fail when the URL is not reachable
completer.set(Result.failure())
}
}
} }
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate=true).build())
}
engine = FlutterEngine(ctx)
private fun doWork() { flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
Log.d(TAG, "doWork") runDart()
val ctx = applicationContext }
if (!flutterLoader.initialized()) { return resolvableFuture
flutterLoader.startInitialization(ctx)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate=true).build())
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
} }
/** /**
@@ -193,7 +139,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
engine = null engine = null
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}") Log.d(TAG, "stopEngine result=${result}")
this.completer.set(result) resolvableFuture.set(result)
} }
waitOnSetForegroundAsync() waitOnSetForegroundAsync()
} }
@@ -324,7 +270,6 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange" const val SHARED_PREF_LAST_CHANGE = "lastChange"
const val SHARED_PREF_SERVER_URL = "serverUrl"
private const val TASK_NAME_BACKUP = "immich/BackupWorker" private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
@@ -401,7 +346,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
.setRequiresBatteryNotLow(true) .setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging) .setRequiresCharging(requireCharging)
.build(); .build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
@@ -415,26 +360,3 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
private const val TAG = "BackupWorker" private const val TAG = "BackupWorker"
/**
* Check if the given URL is reachable via HTTP
*/
suspend fun isUrlReachableHttp(url: String, timeoutMillis: Long = 5000L): Boolean {
return withTimeoutOrNull(timeoutMillis) {
var httpURLConnection: HttpURLConnection? = null
try {
httpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "HEAD"
connectTimeout = timeoutMillis.toInt()
readTimeout = timeoutMillis.toInt()
}
httpURLConnection.connect()
httpURLConnection.responseCode == HttpURLConnection.HTTP_OK
} catch (e: Exception) {
Log.e(TAG, "Failed to reach server URL: $e")
false
} finally {
httpURLConnection?.disconnect()
}
} == true
}
+4 -18
View File
@@ -1,21 +1,3 @@
buildscript {
ext.kotlin_version = '1.8.22'
ext.kotlin_coroutines_version = '1.7.1'
ext.work_version = '2.9.0'
ext.concurrent_version = '1.1.0'
ext.guava_version = '33.0.0-android'
ext.glide_version = '4.14.2'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -34,3 +16,7 @@ subprojects {
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }
tasks.named('wrapper') {
distributionType = Wrapper.DistributionType.ALL
}
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 134, "android.injected.version.code" => 136,
"android.injected.version.name" => "1.102.2", "android.injected.version.name" => "1.102.3",
} }
) )
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')
+3 -3
View File
@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000261">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.670134"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="32.48099">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.759668"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.236974">
</testcase> </testcase>
+3 -2
View File
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 distributionSha256Sum=fe696c020f241a5f69c30f763c5a7f38eec54b490db19cd2b0962dda420d7d12
+23 -8
View File
@@ -1,11 +1,26 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "7.4.2" apply false
id "org.jetbrains.kotlin.android" version "1.9.23" apply false
id "org.jetbrains.kotlin.kapt" version "1.9.23" apply false
}
include ":app"
+1
View File
@@ -296,6 +296,7 @@
"motion_photos_page_title": "Motion Photos", "motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",
"no_assets_to_show" : "No assets to show",
"notification_permission_dialog_cancel": "Cancel", "notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings", "notification_permission_dialog_settings": "Settings",
+4 -1
View File
@@ -296,6 +296,7 @@
"motion_photos_page_title": "Photos avec mouvement", "motion_photos_page_title": "Photos avec mouvement",
"multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.", "multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.",
"multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.", "multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.",
"no_assets_to_show" : "Aucun élément à afficher",
"notification_permission_dialog_cancel": "Annuler", "notification_permission_dialog_cancel": "Annuler",
"notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.", "notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.",
"notification_permission_dialog_settings": "Paramètres", "notification_permission_dialog_settings": "Paramètres",
@@ -509,5 +510,7 @@
"version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89", "version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89",
"viewer_remove_from_stack": "Retirer de la pile", "viewer_remove_from_stack": "Retirer de la pile",
"viewer_stack_use_as_main_asset": "Utiliser comme élément principal", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal",
"viewer_unstack": "Désempiler" "viewer_unstack": "Désempiler",
"haptic_feedback_title": "Retour haptique",
"haptic_feedback_switch": "Activer le retour haptique"
} }
+1 -1
View File
@@ -58,7 +58,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.102.1</string> <string>1.102.2</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
+1 -1
View File
@@ -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.102.2" version_number: "1.102.3"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+10 -5
View File
@@ -5,27 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000226"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000231">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.160617"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.155919">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="2.966255"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.252784">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.184278"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.210502">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="160.892368"> <testcase classname="fastlane.lanes" name="4: build_app" time="175.813647">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.512517">
</testcase> </testcase>
@@ -20,7 +20,6 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@@ -69,10 +68,8 @@ class BackgroundService {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title = final String title =
"backup_background_service_default_notification".tr(); "backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod( final bool ok = await _foregroundChannel
'enable', .invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
[callback.toRawHandle(), title, immediate, getServerUrl()],
);
return ok; return ok;
} catch (error) { } catch (error) {
return false; return false;
@@ -63,7 +63,7 @@ class MultiselectGrid extends HookConsumerWidget {
const Center(child: ImmichLoadingIndicator()); const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() => Widget buildEmptyIndicator() =>
emptyIndicator ?? const Center(child: Text("No assets to show")); emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr());
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.102.2 - API version: 1.102.3
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements ## Requirements
+1 -1
View File
@@ -1804,5 +1804,5 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.2.0 <4.0.0" dart: ">=3.3.0 <4.0.0"
flutter: ">=3.16.0" flutter: ">=3.16.0"
+2 -2
View File
@@ -2,10 +2,10 @@ 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.102.2+134 version: 1.102.3+136
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
+1 -1
View File
@@ -7078,7 +7078,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.102.2", "version": "1.102.3",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.102.2 * 1.102.3
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+2 -1
View File
@@ -27,7 +27,8 @@
"matchFileNames": ["mobile/**"], "matchFileNames": ["mobile/**"],
"groupName": "mobile", "groupName": "mobile",
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"schedule": "on tuesday" "schedule": "on tuesday",
"addLabels": ["📱mobile"]
}, },
{ {
"groupName": "exiftool", "groupName": "exiftool",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.102.2", "version": "1.102.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^10.0.1", "@nestjs/bullmq": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.102.2", "version": "1.102.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+6 -4
View File
@@ -2,10 +2,12 @@ import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository'; export const ISessionRepository = 'ISessionRepository';
type E = SessionEntity;
export interface ISessionRepository { export interface ISessionRepository {
create(dto: Partial<SessionEntity>): Promise<SessionEntity>; create<T extends Partial<E>>(dto: T): Promise<T>;
update(dto: Partial<SessionEntity>): Promise<SessionEntity>; update<T extends Partial<E>>(dto: T): Promise<T>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
getByToken(token: string): Promise<SessionEntity | null>; getByToken(token: string): Promise<E | null>;
getByUserId(userId: string): Promise<SessionEntity[]>; getByUserId(userId: string): Promise<E[]>;
} }
@@ -31,12 +31,12 @@ export class SessionRepository implements ISessionRepository {
}); });
} }
create(session: Partial<SessionEntity>): Promise<SessionEntity> { create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> {
return this.repository.save(session); return this.repository.save(dto);
} }
update(session: Partial<SessionEntity>): Promise<SessionEntity> { update<T extends Partial<SessionEntity>>(dto: T): Promise<T> {
return this.repository.save(session); return this.repository.save(dto);
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
+1 -4
View File
@@ -340,10 +340,7 @@ describe('AuthService', () => {
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
sessionMock.update.mockResolvedValue(sessionStub.valid); sessionMock.update.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({ await expect(sut.validate(headers, {})).resolves.toBeDefined();
user: userStub.user1,
session: sessionStub.valid,
});
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
}); });
}); });
+2 -2
View File
@@ -374,14 +374,14 @@ export class AuthService {
private async validateSession(tokenValue: string): Promise<AuthDto> { private async validateSession(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let session = await this.sessionRepository.getByToken(hashedToken); const session = await this.sessionRepository.getByToken(hashedToken);
if (session?.user) { if (session?.user) {
const now = DateTime.now(); const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(session.updatedAt); const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']); const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) { if (diff.hours > 1) {
session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
} }
return { user: session.user, session: session }; return { user: session.user, session: session };
+1 -1
View File
@@ -436,7 +436,7 @@ export class AV1Config extends BaseConfig {
export class NVENCConfig extends BaseHWConfig { export class NVENCConfig extends BaseHWConfig {
getSupportedCodecs() { getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC]; return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1];
} }
getBaseInputOptions() { getBaseInputOptions() {
@@ -3,8 +3,8 @@ import { Mocked, vitest } from 'vitest';
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => { export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
return { return {
create: vitest.fn(), create: vitest.fn() as any,
update: vitest.fn(), update: vitest.fn() as any,
delete: vitest.fn(), delete: vitest.fn(),
getByToken: vitest.fn(), getByToken: vitest.fn(),
getByUserId: vitest.fn(), getByUserId: vitest.fn(),
+21 -3
View File
@@ -1,17 +1,19 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.102.2", "version": "1.102.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/core": "^5.7.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6", "@zoom-image/svelte": "^0.2.6",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
@@ -63,7 +65,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
@@ -1590,6 +1592,22 @@
"three": "^0.161.0" "three": "^0.161.0"
} }
}, },
"node_modules/@photo-sphere-viewer/equirectangular-video-adapter": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.7.2.tgz",
"integrity": "sha512-cAaot52nPqa2p77Xp1humRvuxRIa8cqbZ/XRhA8kBToFLT1Ugh9YBcDD7pM/358JtAjicUbLpT7Ioap9iEigxQ==",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.7.2"
}
},
"node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.7.2.tgz",
"integrity": "sha512-vrPV9RCr4HsYiORkto1unDPeUkbN2kbyogvNUoLiQ78M4xkPOqoKxtfxCxTYoM+7gECwNL9VTF81+okck498qA==",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.7.2"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.24", "version": "1.0.0-next.24",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.102.2", "version": "1.102.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -61,6 +61,8 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/core": "^5.7.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6", "@zoom-image/svelte": "^0.2.6",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
@@ -50,7 +50,7 @@
import PanoramaViewer from './panorama-viewer.svelte'; import PanoramaViewer from './panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-viewer.svelte'; import VideoViewer from './video-wrapper-viewer.svelte';
export let assetStore: AssetStore | null = null; export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
@@ -622,6 +622,7 @@
{:else} {:else}
<VideoViewer <VideoViewer
assetId={previewStackedAsset.id} assetId={previewStackedAsset.id}
projectionType={previewStackedAsset.exifInfo?.projectionType}
on:close={closeViewer} on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()} on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted} on:onVideoStarted={handleVideoStarted}
@@ -642,6 +643,7 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer <VideoViewer
assetId={asset.livePhotoVideoId} assetId={asset.livePhotoVideoId}
projectionType={asset.exifInfo?.projectionType}
on:close={closeViewer} on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/> />
@@ -655,6 +657,7 @@
{:else} {:else}
<VideoViewer <VideoViewer
assetId={asset.id} assetId={asset.id}
projectionType={asset.exifInfo?.projectionType}
on:close={closeViewer} on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()} on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted} on:onVideoStarted={handleVideoStarted}
@@ -1,22 +1,39 @@
<script lang="ts"> <script lang="ts">
import { serveFile, type AssetResponseDto } from '@immich/sdk'; import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { getKey } from '$lib/utils'; import { getKey } from '$lib/utils';
export let asset: AssetResponseDto; import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
export let asset: Pick<AssetResponseDto, 'id' | 'type'>;
const photoSphereConfigs =
asset.type === AssetTypeEnum.Video
? ([
import('@photo-sphere-viewer/equirectangular-video-adapter').then(
({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter,
),
import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]),
true,
import('@photo-sphere-viewer/video-plugin/index.css'),
] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown])
: ([undefined, [], false] as [undefined, [], false]);
const loadAssetData = async () => { const loadAssetData = async () => {
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() }); const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
return URL.createObjectURL(data); const url = URL.createObjectURL(data);
if (asset.type === AssetTypeEnum.Video) {
return { source: url };
}
return url;
}; };
</script> </script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data --> <!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data -->
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte')])} {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, module]} {:then [data, module, adapter, plugins, navbar]}
<svelte:component this={module.default} panorama={data} /> <svelte:component this={module.default} panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} />
{:catch} {:catch}
Failed to load asset Failed to load asset
{/await} {/await}
@@ -1,17 +1,32 @@
<script lang="ts"> <script lang="ts">
import { Viewer } from '@photo-sphere-viewer/core'; import {
Viewer,
EquirectangularAdapter,
type PluginConstructor,
type AdapterConstructor,
} from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css'; import '@photo-sphere-viewer/core/index.css';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
export let panorama: string; export let panorama: string | { source: string };
export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter;
export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = [];
export let navbar = false;
let container: HTMLDivElement; let container: HTMLDivElement;
let viewer: Viewer; let viewer: Viewer;
onMount(() => { onMount(() => {
viewer = new Viewer({ viewer = new Viewer({
adapter,
plugins,
container, container,
panorama, panorama,
navbar: false, touchmoveTwoFingers: true,
mousewheelCtrlKey: false,
navbar,
maxFov: 180,
fisheye: true,
}); });
}); });
@@ -14,7 +14,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
@@ -150,15 +150,24 @@
<div <div
bind:this={element} bind:this={element}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="flex h-full select-none place-content-center place-items-center" class="relative h-full select-none"
> >
{#if !imageLoaded} {#if !imageLoaded}
<LoadingSpinner /> <div class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else} {:else}
<div bind:this={imgElement} class="h-full w-full"> <div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetData}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img <img
bind:this={$photoViewer} bind:this={$photoViewer}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
src={assetData} src={assetData}
alt={getAltText(asset)} alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None class="h-full w-full {$slideshowState === SlideshowState.None
@@ -0,0 +1,15 @@
<script lang="ts">
import { AssetTypeEnum } from '@immich/sdk';
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
export let assetId: string;
export let projectionType: string | null | undefined;
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
{:else}
<VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted />
{/if}
@@ -43,7 +43,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10', 'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50', 'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400', red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90', green: 'bg-green-600 text-white enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray', gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray': 'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25', 'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
@@ -83,6 +83,7 @@
hoverLabel = new Date(attr).toLocaleString($locale, { hoverLabel = new Date(attr).toLocaleString($locale, {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
timeZone: 'UTC',
}); });
}; };
@@ -15,6 +15,7 @@
mdiMagnify, mdiMagnify,
mdiMap, mdiMap,
mdiTrashCanOutline, mdiTrashCanOutline,
mdiTuneVariant,
} from '@mdi/js'; } from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte'; import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte'; import StatusBox from '../status-box.svelte';
@@ -145,6 +146,12 @@
</svelte:fragment> </svelte:fragment>
</SideBarLink> </SideBarLink>
{/if} {/if}
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">AUTOMATION</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<SideBarLink title="Workflows" routeId="/(user)/workflows" icon={mdiTuneVariant} />
</nav> </nav>
<!-- Status Box --> <!-- Status Box -->
@@ -4,7 +4,14 @@
SettingInputFieldType, SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { mdiArrowDownThin, mdiArrowUpThin, mdiFitToPageOutline, mdiFitToScreenOutline, mdiShuffle } from '@mdi/js'; import {
mdiArrowDownThin,
mdiArrowUpThin,
mdiFitToPageOutline,
mdiFitToScreenOutline,
mdiPanorama,
mdiShuffle,
} from '@mdi/js';
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
import Button from './elements/buttons/button.svelte'; import Button from './elements/buttons/button.svelte';
import type { RenderedOption } from './elements/dropdown.svelte'; import type { RenderedOption } from './elements/dropdown.svelte';
@@ -23,6 +30,7 @@
const lookOptions: Record<SlideshowLook, RenderedOption> = { const lookOptions: Record<SlideshowLook, RenderedOption> = {
[SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' }, [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' },
[SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' }, [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' },
[SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: 'Blurred background' },
}; };
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>( const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>(
@@ -0,0 +1,15 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiPencil, mdiTrashCan } from '@mdi/js';
</script>
<div
class="bg-zinc-300 dark:bg-zinc-800 text-black dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
>
<p class="col-start-2 col-span-1">Add to album "RANDOM"</p>
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiPencil, mdiTrashCan } from '@mdi/js';
</script>
<div
class="bg-zinc-200 text-black dark:bg-zinc-700 dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
>
<p class="col-start-2 col-span-1">And has Alex and Henry and Nate</p>
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
</div>
</div>
@@ -0,0 +1,5 @@
<div
class="bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black min-h-[60px] flex place-items-center place-content-center mx-4 mt-2 rounded-2xl"
>
When an asset is uploaded
</div>
@@ -0,0 +1,10 @@
<script lang="ts">
// export let onSelect: () => void;
</script>
<div
class="rounded-3xl mr-2 my-4 min-h-[60px] grid grid-cols-[32px_1fr] gap-2 place-items-center p-4 place-content-center bg-gray-100 hover:bg-gray-200 dark:bg-gray-900 hover:dark:bg-gray-800"
>
<div class="w-4 h-4 rounded-full bg-green-400"></div>
<p class="dark:text-gray-300">Add this photo to every albums and notify everybody about this glorious asset</p>
</div>
@@ -0,0 +1,82 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import WorkflowActionCard from '$lib/components/workflow-page/editor/workflow-action-card.svelte';
import WorkflowRuleCard from '$lib/components/workflow-page/editor/workflow-rule-card.svelte';
import WorkflowTriggerCard from '$lib/components/workflow-page/editor/workflow-trigger-card.svelte';
import { mdiPlus } from '@mdi/js';
</script>
<section class="h-full overflow-scroll">
<div
id="workflow-control-bar"
class="sticky top-0 flex justify-between place-items-center border-b border-gray-200 dark:border-gray-800 p-4 bg-zinc-50 dark:bg-zinc-900 z-20"
>
<p class="uppercase text-lg dark:text-white font-medium">
Add this photo to every albums and notify everybody about this glorious asset
</p>
<div class="flex gap-2">
<Button size="sm" color="red">Discard</Button>
<Button size="sm">Disable</Button>
<Button size="sm" color="green">Save</Button>
</div>
</div>
<div id="workflows-selection">
<!-- TRIGGER BLOCK -->
<div class="translate-y-3">
<p class="pl-4 text-xs dark:text-gray-300">TRIGGER</p>
<WorkflowTriggerCard />
</div>
<!-- VISUAL CONNECTOR -->
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center z-10">
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">RULES</p>
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
<div
class="rounded-full border-[6px] border-immich-primary dark:border-immich-dark-primary h-[20px] w-[20px] bg-white translate-y-1"
></div>
<div
class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-immich-primary dark:from-immich-dark-primary via-zinc-300 dark:via-gray-600 dark:to-zinc-700"
></div>
<div
class="rounded-full border-[6px] border-zinc-200 dark:border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"
></div>
</div>
</div>
<!-- RULES BLOCK -->
<div id="rule-block" class="-translate-y-6">
<WorkflowRuleCard />
<WorkflowRuleCard />
<div
class="border-2 dark:border-gray-700 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
>
<span><Icon path={mdiPlus} /></span> ADD RULE
</div>
</div>
<!-- VISUAL CONNECTOR -->
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center -translate-y-9 z-10">
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">ACTIONS</p>
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
<div class="rounded-full border-[6px] border-gray-900 h-[20px] w-[20px] bg-white translate-y-1"></div>
<div class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-gray-900 via-indigo-800 to-gray-700"></div>
<div class="rounded-full border-[6px] border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"></div>
</div>
</div>
<!-- ACTION BLOCK -->
<div id="action-block" class="-translate-y-14">
<WorkflowActionCard />
<WorkflowActionCard />
<div
class="border-2 dark:border-gray-500 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
>
<span><Icon path={mdiPlus} /></span> ADD ACTION
</div>
</div>
</div>
</section>
@@ -0,0 +1,17 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import WorkflowCard from '$lib/components/workflow-page/workflow-card.svelte';
</script>
<section id="workflow-list" class="border-r border-gray-200 dark:border-gray-800 h-full relative overflow-scroll pr-2">
<div class="sticky top-0 dark:bg-immich-dark-bg flex justify-between place-items-center pr-2 py-4 bg-immich-bg">
<p class="text-xs dark:text-white">CURRENT WORKFLOWS</p>
<Button size="sm">New Workflow</Button>
</div>
<div>
{#each Array.from({ length: 50 }) as _}
<WorkflowCard />
{/each}
</div>
</section>
+2
View File
@@ -16,11 +16,13 @@ export enum SlideshowNavigation {
export enum SlideshowLook { export enum SlideshowLook {
Contain = 'contain', Contain = 'contain',
Cover = 'cover', Cover = 'cover',
BlurredBackground = 'blurred-background',
} }
export const slideshowLookCssMapping: Record<SlideshowLook, string> = { export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
[SlideshowLook.Contain]: 'object-contain', [SlideshowLook.Contain]: 'object-contain',
[SlideshowLook.Cover]: 'object-cover', [SlideshowLook.Cover]: 'object-cover',
[SlideshowLook.BlurredBackground]: 'object-contain',
}; };
function createSlideshowStore() { function createSlideshowStore() {
+10 -2
View File
@@ -39,8 +39,12 @@
try { try {
await emptyTrash(); await emptyTrash();
const deletedAssetIds = assetStore.assets.map((a) => a.id);
const numberOfAssets = deletedAssetIds.length;
assetStore.removeAssets(deletedAssetIds);
notificationController.show({ notificationController.show({
message: `Empty trash initiated. Refresh the page to see the changes`, message: `Permanently deleted ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`,
type: NotificationType.Info, type: NotificationType.Info,
}); });
} catch (error) { } catch (error) {
@@ -52,8 +56,12 @@
try { try {
await restoreTrash(); await restoreTrash();
const restoredAssetIds = assetStore.assets.map((a) => a.id);
const numberOfAssets = restoredAssetIds.length;
assetStore.removeAssets(restoredAssetIds);
notificationController.show({ notificationController.show({
message: `Restore trash initiated. Refresh the page to see the changes`, message: `Restored ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`,
type: NotificationType.Info, type: NotificationType.Info,
}); });
} catch (error) { } catch (error) {
@@ -0,0 +1,15 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import WorkflowEditor from '$lib/components/workflow-page/workflow-editor.svelte';
import WorkflowList from '$lib/components/workflow-page/workflow-list.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
<section class="grid grid-cols-[25%_1fr] h-full">
<WorkflowList />
<WorkflowEditor />
</section>
</UserPageLayout>
+12
View File
@@ -0,0 +1,12 @@
import { authenticate } from '$lib/utils/auth';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate();
return {
meta: {
title: 'Workflows',
},
};
}) satisfies PageLoad;