Compare commits
29 Commits
v1.29.2_43
...
v1.30.2_48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536fda04f2 | ||
|
|
2094204877 | ||
|
|
ab375cca1a | ||
|
|
479f706f8a | ||
|
|
4342285507 | ||
|
|
8bb656cb17 | ||
|
|
3f1f835df3 | ||
|
|
87ca031335 | ||
|
|
96b9e37461 | ||
|
|
0d3a2fe844 | ||
|
|
848781aef5 | ||
|
|
28bf497a0b | ||
|
|
8ede738396 | ||
|
|
40c2b6a563 | ||
|
|
3581cf7305 | ||
|
|
c33775b944 | ||
|
|
b0cd2522e0 | ||
|
|
c3979f6e31 | ||
|
|
103df4d9f3 | ||
|
|
040e02cfc5 | ||
|
|
f377b64065 | ||
|
|
e5459b68ff | ||
|
|
fc194021a4 | ||
|
|
39f8ca3bf1 | ||
|
|
7a807f7216 | ||
|
|
bedfb51b1c | ||
|
|
b2afb95c19 | ||
|
|
10239161fd | ||
|
|
242f10952d |
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '20 13 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript', 'python' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
||||||
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
|
|
||||||
|
# - run: |
|
||||||
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
26
README.md
26
README.md
@@ -23,12 +23,30 @@
|
|||||||
<br/>
|
<br/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
You can access the web demo at https://demo.immich.app
|
||||||
|
|
||||||
|
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
The credential
|
||||||
|
email: demo@immich.app
|
||||||
|
password: demo
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||||
|
```
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshots](#screenshots)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Update](#update)
|
- [Update](#update)
|
||||||
- [Mobile App](#-mobile-app)
|
- [Mobile App](#mobile-app)
|
||||||
|
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Support](#support)
|
- [Support](#support)
|
||||||
- [Known Issues](#known-issues)
|
- [Known Issues](#known-issues)
|
||||||
@@ -146,8 +164,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
|||||||
* Populate custom database information if necessary.
|
* Populate custom database information if necessary.
|
||||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
||||||
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
||||||
* [Optional] Populate Mapbox value to use reverse geocoding.
|
|
||||||
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
|
|
||||||
|
|
||||||
### Step 3 - Start the containers
|
### Step 3 - Start the containers
|
||||||
|
|
||||||
@@ -190,7 +206,11 @@ docker-compose pull && docker-compose up -d
|
|||||||
|
|
||||||
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
|
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
|
||||||
|
|
||||||
|
# App Beta release channel
|
||||||
|
|
||||||
|
You can opt-in to join app beta release channel by following the links below:
|
||||||
|
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||||
|
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
|
|||||||
# Optional Database settings:
|
# Optional Database settings:
|
||||||
# DB_PORT=5432
|
# DB_PORT=5432
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Redis
|
# Redis
|
||||||
###################################################################################
|
###################################################################################
|
||||||
@@ -25,41 +22,39 @@ REDIS_HOSTNAME=immich_redis
|
|||||||
# REDIS_PASSWORD=
|
# REDIS_PASSWORD=
|
||||||
# REDIS_SOCKET=
|
# REDIS_SOCKET=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Upload File Config
|
# Upload File Config
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Log message level - [simple|verbose]
|
# Log message level - [simple|verbose]
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
LOG_LEVEL=simple
|
LOG_LEVEL=simple
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# JWT SECRET
|
# JWT SECRET
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# MAPBOX
|
# Reverse Geocoding
|
||||||
####################################################################################
|
####################################################################################
|
||||||
|
|
||||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
# DISABLE_REVERSE_GEOCODING=false
|
||||||
ENABLE_MAPBOX=false
|
|
||||||
MAPBOX_KEY=
|
|
||||||
|
|
||||||
|
# Reverse geocoding is done locally which has a small impact on memory usage
|
||||||
|
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
|
||||||
|
# This ranges from 0-3 with 3 being the most precise
|
||||||
|
# 3 - Cities > 500 population: ~200MB RAM
|
||||||
|
# 2 - Cities > 1000 population: ~150MB RAM
|
||||||
|
# 1 - Cities > 5000 population: ~80MB RAM
|
||||||
|
# 0 - Cities > 15000 population: ~40MB RAM
|
||||||
|
|
||||||
|
# REVERSE_GEOCODING_PRECISION=3
|
||||||
|
|
||||||
####################################################################################
|
####################################################################################
|
||||||
# WEB - Optional
|
# WEB - Optional
|
||||||
@@ -69,11 +64,3 @@ MAPBOX_KEY=
|
|||||||
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
||||||
|
|
||||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||||
|
|
||||||
# For correctly display your local time zone on the web, you can set the time zone here.
|
|
||||||
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
|
|
||||||
# should be set to the correct timezone.
|
|
||||||
# Command to get timezone:
|
|
||||||
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
|
|
||||||
|
|
||||||
# TZ=Etc/UTC
|
|
||||||
@@ -47,8 +47,6 @@ services:
|
|||||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
|
||||||
- PUBLIC_TZ=${TZ}
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
2227
machine-learning/package-lock.json
generated
2227
machine-learning/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@
|
|||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/core": "^8.0.0",
|
||||||
"@nestjs/mapped-types": "^1.0.1",
|
"@nestjs/mapped-types": "^1.0.1",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
"@nestjs/typeorm": "^8.0.3",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||||
"@tensorflow/tfjs": "^3.19.0",
|
"@tensorflow/tfjs": "^3.19.0",
|
||||||
@@ -34,11 +33,9 @@
|
|||||||
"@tensorflow/tfjs-node": "^3.19.0",
|
"@tensorflow/tfjs-node": "^3.19.0",
|
||||||
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
||||||
"@trpc/server": "^9.20.3",
|
"@trpc/server": "^9.20.3",
|
||||||
"pg": "^8.7.3",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0"
|
||||||
"typeorm": "^0.2.45"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.2.4",
|
"@nestjs/cli": "^8.2.4",
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||||
import { databaseConfig } from './config/database.config';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [ImageClassifierModule, ObjectDetectionModule],
|
||||||
TypeOrmModule.forRoot(databaseConfig),
|
|
||||||
ImageClassifierModule,
|
|
||||||
ObjectDetectionModule,
|
|
||||||
],
|
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
export const databaseConfig: TypeOrmModuleOptions = {
|
|
||||||
type: 'postgres',
|
|
||||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
|
||||||
port: parseInt(process.env.DB_PORT || '5432'),
|
|
||||||
username: process.env.DB_USERNAME,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_DATABASE_NAME,
|
|
||||||
synchronize: false,
|
|
||||||
};
|
|
||||||
@@ -16,12 +16,17 @@
|
|||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Build Android"
|
desc "Build Android and Release Testing"
|
||||||
lane :build do
|
lane :beta do
|
||||||
gradle(
|
gradle(
|
||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
|
properties: {
|
||||||
|
"android.injected.version.code" => 47,
|
||||||
|
"android.injected.version.name" => "1.30.2",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
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', track: 'beta')
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build and Release Android"
|
desc "Build and Release Android"
|
||||||
@@ -30,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 43,
|
"android.injected.version.code" => 48,
|
||||||
"android.injected.version.name" => "1.29.1",
|
"android.injected.version.name" => "1.30.2",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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')
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
|||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
### android build
|
### android beta
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane android build
|
[bundle exec] fastlane android beta
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Android
|
Build Android and Release Testing
|
||||||
|
|
||||||
### android release
|
### android release
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fixed app crashes when there is no object detection result.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Correctly display time based on timezone
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Added improvement for timeline view
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Improve scroll thumb date info
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fixed parsing date error prevent timeline to be loaded.
|
||||||
@@ -5,17 +5,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.768014">
|
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -165,5 +165,10 @@
|
|||||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
|
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||||
|
"experimental_settings_title": "Experimental",
|
||||||
|
"experimental_settings_subtitle": "Use at your own risk!",
|
||||||
|
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||||
|
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||||
|
"settings_require_restart": "Please restart Immich to apply this setting"
|
||||||
}
|
}
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.30.0</string>
|
<string>1.30.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>58</string>
|
<string>62</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true />
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
|
|||||||
@@ -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.29.1"
|
version_number: "1.30.2"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -5,32 +5,32 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.412813">
|
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.69289">
|
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563">
|
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="4: build_app" time="65.350555">
|
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733">
|
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||||
Text(
|
Text(
|
||||||
DateFormat('date_format'.tr()).format(
|
DateFormat('date_format'.tr()).format(
|
||||||
assetDetail.exifInfo!.dateTimeOriginal!,
|
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
backupState.currentUploadAsset.createdAt
|
backupState.currentUploadAsset.createdAt
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
|||||||
DateFormat.yMMMMd('en_US').format(
|
DateFormat.yMMMMd('en_US').format(
|
||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
errorAsset.createdAt.toString(),
|
errorAsset.createdAt.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.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';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
enum RenderAssetGridElementType {
|
||||||
|
assetRow,
|
||||||
|
dayTitle,
|
||||||
|
monthTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderAssetGridRow {
|
||||||
|
final List<AssetResponseDto> assets;
|
||||||
|
|
||||||
|
RenderAssetGridRow(this.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderAssetGridElement {
|
||||||
|
final RenderAssetGridElementType type;
|
||||||
|
final RenderAssetGridRow? assetRow;
|
||||||
|
final String? title;
|
||||||
|
final DateTime date;
|
||||||
|
final List<AssetResponseDto>? relatedAssetList;
|
||||||
|
|
||||||
|
RenderAssetGridElement(
|
||||||
|
this.type, {
|
||||||
|
this.assetRow,
|
||||||
|
this.title,
|
||||||
|
required this.date,
|
||||||
|
this.relatedAssetList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final renderListProvider = StateProvider((ref) {
|
||||||
|
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
|
||||||
|
var settings = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
|
||||||
|
|
||||||
|
List<RenderAssetGridElement> elements = [];
|
||||||
|
DateTime? lastDate;
|
||||||
|
|
||||||
|
assetGroups.forEach((groupName, assets) {
|
||||||
|
try {
|
||||||
|
final date = DateTime.parse(groupName);
|
||||||
|
|
||||||
|
if (lastDate == null || lastDate!.month != date.month) {
|
||||||
|
elements.add(
|
||||||
|
RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.monthTitle,
|
||||||
|
title: groupName,
|
||||||
|
date: date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add group title
|
||||||
|
elements.add(
|
||||||
|
RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.dayTitle,
|
||||||
|
title: groupName,
|
||||||
|
date: date,
|
||||||
|
relatedAssetList: assets,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add rows
|
||||||
|
int cursor = 0;
|
||||||
|
while (cursor < assets.length) {
|
||||||
|
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||||
|
|
||||||
|
final rowElement = RenderAssetGridElement(
|
||||||
|
RenderAssetGridElementType.assetRow,
|
||||||
|
date: date,
|
||||||
|
assetRow: RenderAssetGridRow(
|
||||||
|
assets.sublist(cursor, cursor + rowElements),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.add(rowElement);
|
||||||
|
cursor += rowElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDate = date;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
});
|
||||||
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
class DailyTitleText extends ConsumerWidget {
|
||||||
|
const DailyTitleText({
|
||||||
|
Key? key,
|
||||||
|
required this.isoDate,
|
||||||
|
required this.assetGroup,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final String isoDate;
|
||||||
|
final List<AssetResponseDto> assetGroup;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var currentYear = DateTime.now().year;
|
||||||
|
var groupYear = DateTime.parse(isoDate).year;
|
||||||
|
var formatDateTemplate = currentYear == groupYear
|
||||||
|
? "daily_title_text_date".tr()
|
||||||
|
: "daily_title_text_date_year".tr();
|
||||||
|
var dateText =
|
||||||
|
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||||
|
var isMultiSelectEnable =
|
||||||
|
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
|
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||||
|
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
||||||
|
|
||||||
|
void _handleTitleIconClick() {
|
||||||
|
if (isMultiSelectEnable &&
|
||||||
|
selectedDateGroup.contains(dateText) &&
|
||||||
|
selectedDateGroup.length == 1 &&
|
||||||
|
selectedItems.length <= assetGroup.length) {
|
||||||
|
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
|
||||||
|
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||||
|
} else if (isMultiSelectEnable &&
|
||||||
|
selectedDateGroup.contains(dateText) &&
|
||||||
|
selectedItems.length != assetGroup.length) {
|
||||||
|
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.removeSelectedDateGroup(dateText);
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.removeMultipleSelectedItem(assetGroup);
|
||||||
|
} else if (isMultiSelectEnable &&
|
||||||
|
selectedDateGroup.contains(dateText) &&
|
||||||
|
selectedDateGroup.length > 1) {
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.removeSelectedDateGroup(dateText);
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.removeMultipleSelectedItem(assetGroup);
|
||||||
|
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.addSelectedDateGroup(dateText);
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.addMultipleSelectedItems(assetGroup);
|
||||||
|
} else {
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.enableMultiSelect(assetGroup.toSet());
|
||||||
|
ref
|
||||||
|
.watch(homePageStateProvider.notifier)
|
||||||
|
.addSelectedDateGroup(dateText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 29.0,
|
||||||
|
bottom: 29.0,
|
||||||
|
left: 12.0,
|
||||||
|
right: 12.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dateText,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _handleTitleIconClick,
|
||||||
|
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
|
/// Build the Scroll Thumb and label using the current configuration
|
||||||
|
typedef ScrollThumbBuilder = Widget Function(
|
||||||
|
Color backgroundColor,
|
||||||
|
Animation<double> thumbAnimation,
|
||||||
|
Animation<double> labelAnimation,
|
||||||
|
double height, {
|
||||||
|
Text? labelText,
|
||||||
|
BoxConstraints? labelConstraints,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Build a Text widget using the current scroll offset
|
||||||
|
typedef LabelTextBuilder = Text Function(int item);
|
||||||
|
|
||||||
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
|
/// for quick navigation of the BoxScrollView.
|
||||||
|
class DraggableScrollbar extends StatefulWidget {
|
||||||
|
/// The view that will be scrolled with the scroll thumb
|
||||||
|
final ScrollablePositionedList child;
|
||||||
|
|
||||||
|
final ItemPositionsListener itemPositionsListener;
|
||||||
|
|
||||||
|
/// A function that builds a thumb using the current configuration
|
||||||
|
final ScrollThumbBuilder scrollThumbBuilder;
|
||||||
|
|
||||||
|
/// The height of the scroll thumb
|
||||||
|
final double heightScrollThumb;
|
||||||
|
|
||||||
|
/// The background color of the label and thumb
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// The amount of padding that should surround the thumb
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
|
||||||
|
/// Determines how quickly the scrollbar will animate in and out
|
||||||
|
final Duration scrollbarAnimationDuration;
|
||||||
|
|
||||||
|
/// How long should the thumb be visible before fading out
|
||||||
|
final Duration scrollbarTimeToFade;
|
||||||
|
|
||||||
|
/// Build a Text widget from the current offset in the BoxScrollView
|
||||||
|
final LabelTextBuilder? labelTextBuilder;
|
||||||
|
|
||||||
|
/// Determines box constraints for Container displaying label
|
||||||
|
final BoxConstraints? labelConstraints;
|
||||||
|
|
||||||
|
/// The ScrollController for the BoxScrollView
|
||||||
|
final ItemScrollController controller;
|
||||||
|
|
||||||
|
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
|
||||||
|
final bool alwaysVisibleScrollThumb;
|
||||||
|
|
||||||
|
final Function(bool scrolling) scrollStateListener;
|
||||||
|
|
||||||
|
DraggableScrollbar.semicircle({
|
||||||
|
Key? key,
|
||||||
|
Key? scrollThumbKey,
|
||||||
|
this.alwaysVisibleScrollThumb = false,
|
||||||
|
required this.child,
|
||||||
|
required this.controller,
|
||||||
|
required this.itemPositionsListener,
|
||||||
|
required this.scrollStateListener,
|
||||||
|
this.heightScrollThumb = 48.0,
|
||||||
|
this.backgroundColor = Colors.white,
|
||||||
|
this.padding,
|
||||||
|
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||||
|
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||||
|
this.labelTextBuilder,
|
||||||
|
this.labelConstraints,
|
||||||
|
}) : assert(child.scrollDirection == Axis.vertical),
|
||||||
|
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||||
|
heightScrollThumb * 0.6,
|
||||||
|
scrollThumbKey,
|
||||||
|
alwaysVisibleScrollThumb,
|
||||||
|
),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||||
|
|
||||||
|
static buildScrollThumbAndLabel({
|
||||||
|
required Widget scrollThumb,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Animation<double>? thumbAnimation,
|
||||||
|
required Animation<double>? labelAnimation,
|
||||||
|
required Text? labelText,
|
||||||
|
required BoxConstraints? labelConstraints,
|
||||||
|
required bool alwaysVisibleScrollThumb,
|
||||||
|
}) {
|
||||||
|
var scrollThumbAndLabel = labelText == null
|
||||||
|
? scrollThumb
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ScrollLabel(
|
||||||
|
animation: labelAnimation,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
constraints: labelConstraints,
|
||||||
|
child: labelText,
|
||||||
|
),
|
||||||
|
scrollThumb,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alwaysVisibleScrollThumb) {
|
||||||
|
return scrollThumbAndLabel;
|
||||||
|
}
|
||||||
|
return SlideFadeTransition(
|
||||||
|
animation: thumbAnimation!,
|
||||||
|
child: scrollThumbAndLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||||
|
double width,
|
||||||
|
Key? scrollThumbKey,
|
||||||
|
bool alwaysVisibleScrollThumb,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
Color backgroundColor,
|
||||||
|
Animation<double> thumbAnimation,
|
||||||
|
Animation<double> labelAnimation,
|
||||||
|
double height, {
|
||||||
|
Text? labelText,
|
||||||
|
BoxConstraints? labelConstraints,
|
||||||
|
}) {
|
||||||
|
final scrollThumb = CustomPaint(
|
||||||
|
key: scrollThumbKey,
|
||||||
|
foregroundPainter: ArrowCustomPainter(Colors.white),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(height),
|
||||||
|
bottomLeft: Radius.circular(height),
|
||||||
|
topRight: const Radius.circular(4.0),
|
||||||
|
bottomRight: const Radius.circular(4.0),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints.tight(Size(width, height)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildScrollThumbAndLabel(
|
||||||
|
scrollThumb: scrollThumb,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
thumbAnimation: thumbAnimation,
|
||||||
|
labelAnimation: labelAnimation,
|
||||||
|
labelText: labelText,
|
||||||
|
labelConstraints: labelConstraints,
|
||||||
|
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollLabel extends StatelessWidget {
|
||||||
|
final Animation<double>? animation;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Text child;
|
||||||
|
|
||||||
|
final BoxConstraints? constraints;
|
||||||
|
static const BoxConstraints _defaultConstraints =
|
||||||
|
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||||
|
|
||||||
|
const ScrollLabel({
|
||||||
|
Key? key,
|
||||||
|
required this.child,
|
||||||
|
required this.animation,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.constraints = _defaultConstraints,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation!,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12.0),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||||
|
child: Container(
|
||||||
|
constraints: constraints ?? _defaultConstraints,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late double _barOffset;
|
||||||
|
late bool _isDragInProcess;
|
||||||
|
late int _currentItem;
|
||||||
|
|
||||||
|
late AnimationController _thumbAnimationController;
|
||||||
|
late Animation<double> _thumbAnimation;
|
||||||
|
late AnimationController _labelAnimationController;
|
||||||
|
late Animation<double> _labelAnimation;
|
||||||
|
Timer? _fadeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_barOffset = 0.0;
|
||||||
|
_isDragInProcess = false;
|
||||||
|
_currentItem = 0;
|
||||||
|
|
||||||
|
_thumbAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_thumbAnimation = CurvedAnimation(
|
||||||
|
parent: _thumbAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimation = CurvedAnimation(
|
||||||
|
parent: _labelAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_thumbAnimationController.dispose();
|
||||||
|
_labelAnimationController.dispose();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
double get barMaxScrollExtent =>
|
||||||
|
(context.size?.height ?? 0) - widget.heightScrollThumb;
|
||||||
|
|
||||||
|
double get barMinScrollExtent => 0;
|
||||||
|
|
||||||
|
int get maxItemCount => widget.child.itemCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Text? labelText;
|
||||||
|
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||||
|
labelText = widget.labelTextBuilder!(_currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
//print("LayoutBuilder constraints=$constraints");
|
||||||
|
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (ScrollNotification notification) {
|
||||||
|
changePosition(notification);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
RepaintBoundary(
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragStart: _onVerticalDragStart,
|
||||||
|
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||||
|
onVerticalDragEnd: _onVerticalDragEnd,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
margin: EdgeInsets.only(top: _barOffset),
|
||||||
|
padding: widget.padding,
|
||||||
|
child: widget.scrollThumbBuilder(
|
||||||
|
widget.backgroundColor,
|
||||||
|
_thumbAnimation,
|
||||||
|
_labelAnimation,
|
||||||
|
widget.heightScrollThumb,
|
||||||
|
labelText: labelText,
|
||||||
|
labelConstraints: widget.labelConstraints,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll bar has received notification that it's view was scrolled
|
||||||
|
// so it should also changes his position
|
||||||
|
// but only if it isn't dragged
|
||||||
|
changePosition(ScrollNotification notification) {
|
||||||
|
if (_isDragInProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
int firstItemIndex =
|
||||||
|
widget.itemPositionsListener.itemPositions.value.first.index;
|
||||||
|
|
||||||
|
if (notification is ScrollUpdateNotification) {
|
||||||
|
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
|
||||||
|
|
||||||
|
if (_barOffset < barMinScrollExtent) {
|
||||||
|
_barOffset = barMinScrollExtent;
|
||||||
|
}
|
||||||
|
if (_barOffset > barMaxScrollExtent) {
|
||||||
|
_barOffset = barMaxScrollExtent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollUpdateNotification ||
|
||||||
|
notification is OverscrollNotification) {
|
||||||
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
|
_thumbAnimationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemPos < maxItemCount) {
|
||||||
|
_currentItem = itemPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||||
|
_thumbAnimationController.reverse();
|
||||||
|
_labelAnimationController.reverse();
|
||||||
|
_fadeoutTimer = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragStart(DragStartDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isDragInProcess = true;
|
||||||
|
_labelAnimationController.forward();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.scrollStateListener(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get itemPos {
|
||||||
|
int numberOfItems = widget.child.itemCount;
|
||||||
|
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _jumpToBarPos() {
|
||||||
|
if (itemPos > maxItemCount - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentItem = itemPos;
|
||||||
|
|
||||||
|
widget.controller.jumpTo(
|
||||||
|
index: itemPos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer? dragHaltTimer;
|
||||||
|
int lastTimerPos = 0;
|
||||||
|
|
||||||
|
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
setState(() {
|
||||||
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
|
_thumbAnimationController.forward();
|
||||||
|
}
|
||||||
|
if (_isDragInProcess) {
|
||||||
|
_barOffset += details.delta.dy;
|
||||||
|
|
||||||
|
if (_barOffset < barMinScrollExtent) {
|
||||||
|
_barOffset = barMinScrollExtent;
|
||||||
|
}
|
||||||
|
if (_barOffset > barMaxScrollExtent) {
|
||||||
|
_barOffset = barMaxScrollExtent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemPos != lastTimerPos) {
|
||||||
|
lastTimerPos = itemPos;
|
||||||
|
dragHaltTimer?.cancel();
|
||||||
|
widget.scrollStateListener(true);
|
||||||
|
|
||||||
|
dragHaltTimer = Timer(
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
() {
|
||||||
|
widget.scrollStateListener(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_jumpToBarPos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragEnd(DragEndDetails details) {
|
||||||
|
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||||
|
_thumbAnimationController.reverse();
|
||||||
|
_labelAnimationController.reverse();
|
||||||
|
_fadeoutTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_jumpToBarPos();
|
||||||
|
_isDragInProcess = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.scrollStateListener(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws 2 triangles like arrow up and arrow down
|
||||||
|
class ArrowCustomPainter extends CustomPainter {
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
ArrowCustomPainter(this.color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = color;
|
||||||
|
const width = 12.0;
|
||||||
|
const height = 8.0;
|
||||||
|
final baseX = size.width / 2;
|
||||||
|
final baseY = size.height / 2;
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||||
|
return Path()
|
||||||
|
..moveTo(o.dx, o.dy)
|
||||||
|
..lineTo(o.dx + width, o.dy)
|
||||||
|
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||||
|
..close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///This cut 2 lines in arrow shape
|
||||||
|
class ArrowClipper extends CustomClipper<Path> {
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) {
|
||||||
|
Path path = Path();
|
||||||
|
path.lineTo(0.0, size.height);
|
||||||
|
path.lineTo(size.width, size.height);
|
||||||
|
path.lineTo(size.width, 0.0);
|
||||||
|
path.lineTo(0.0, 0.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
double arrowWidth = 8.0;
|
||||||
|
double startPointX = (size.width - arrowWidth) / 2;
|
||||||
|
double startPointY = size.height / 2 - arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||||
|
path.lineTo(
|
||||||
|
startPointX + arrowWidth / 2,
|
||||||
|
startPointY - arrowWidth / 2 + 1.0,
|
||||||
|
);
|
||||||
|
path.lineTo(startPointX, startPointY + 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
startPointY = size.height / 2 + arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX, startPointY - 1.0);
|
||||||
|
path.lineTo(
|
||||||
|
startPointX + arrowWidth / 2,
|
||||||
|
startPointY + arrowWidth / 2 - 1.0,
|
||||||
|
);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlideFadeTransition extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SlideFadeTransition({
|
||||||
|
Key? key,
|
||||||
|
required this.animation,
|
||||||
|
required this.child,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) =>
|
||||||
|
animation.value == 0.0 ? const SizedBox() : child!,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0.3, 0.0),
|
||||||
|
end: const Offset(0.0, 0.0),
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/src/widgets/framework.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
|
import '../thumbnail_image.dart';
|
||||||
|
|
||||||
|
class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
|
final List<RenderAssetGridElement> renderList;
|
||||||
|
final int assetsPerRow;
|
||||||
|
final double margin;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
|
ImmichAssetGrid({
|
||||||
|
super.key,
|
||||||
|
required this.renderList,
|
||||||
|
required this.assetsPerRow,
|
||||||
|
required this.showStorageIndicator,
|
||||||
|
this.margin = 5.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
List<AssetResponseDto> get _assets {
|
||||||
|
return renderList
|
||||||
|
.map((e) {
|
||||||
|
if (e.type == RenderAssetGridElementType.assetRow) {
|
||||||
|
return e.assetRow!.assets;
|
||||||
|
} else {
|
||||||
|
return List<AssetResponseDto>.empty();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flattened
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getItemSize(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width / assetsPerRow -
|
||||||
|
margin * (assetsPerRow - 1) / assetsPerRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThumbnailOrPlaceholder(
|
||||||
|
AssetResponseDto asset, bool placeholder) {
|
||||||
|
if (placeholder) {
|
||||||
|
return const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ThumbnailImage(
|
||||||
|
asset: asset,
|
||||||
|
assetList: _assets,
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
|
useGrayBoxPlaceholder: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssetRow(
|
||||||
|
BuildContext context, RenderAssetGridRow row, bool scrolling) {
|
||||||
|
double size = _getItemSize(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
key: Key("asset-row-${row.assets.first.id}"),
|
||||||
|
children: row.assets.map((AssetResponseDto asset) {
|
||||||
|
bool last = asset == row.assets.last;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: Key("asset-${asset.id}"),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
|
||||||
|
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle(
|
||||||
|
BuildContext context, String title, List<AssetResponseDto> assets) {
|
||||||
|
return DailyTitleText(
|
||||||
|
isoDate: title,
|
||||||
|
assetGroup: assets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||||
|
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||||
|
.format(DateTime.parse(title));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: Key("month-$title"),
|
||||||
|
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||||
|
child: Text(
|
||||||
|
monthTitleText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).textTheme.headline1?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
|
||||||
|
final item = renderList[position];
|
||||||
|
|
||||||
|
if (item.type == RenderAssetGridElementType.dayTitle) {
|
||||||
|
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||||
|
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||||
|
return _buildMonthTitle(c, item.title!);
|
||||||
|
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||||
|
return _buildAssetRow(c, item.assetRow!, scrolling);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Text("Invalid widget type!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Text _labelBuilder(int pos) {
|
||||||
|
final date = renderList[pos].date;
|
||||||
|
return Text(DateFormat.yMMMd().format(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final scrolling = useState(false);
|
||||||
|
|
||||||
|
void dragScrolling(bool active) {
|
||||||
|
scrolling.value = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget itemBuilder(BuildContext c, int position) {
|
||||||
|
return _itemBuilder(c, position, scrolling.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DraggableScrollbar.semicircle(
|
||||||
|
scrollStateListener: dragScrolling,
|
||||||
|
itemPositionsListener: _itemPositionsListener,
|
||||||
|
controller: _itemScrollController,
|
||||||
|
backgroundColor: Theme.of(context).hintColor,
|
||||||
|
labelTextBuilder: _labelBuilder,
|
||||||
|
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||||
|
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||||
|
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||||
|
child: ScrollablePositionedList.builder(
|
||||||
|
itemBuilder: itemBuilder,
|
||||||
|
itemPositionsListener: _itemPositionsListener,
|
||||||
|
itemScrollController: _itemScrollController,
|
||||||
|
itemCount: renderList.length,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
|
|||||||
var formatDateTemplate = currentYear == groupYear
|
var formatDateTemplate = currentYear == groupYear
|
||||||
? "daily_title_text_date".tr()
|
? "daily_title_text_date".tr()
|
||||||
: "daily_title_text_date_year".tr();
|
: "daily_title_text_date_year".tr();
|
||||||
var dateText =
|
var dateText = DateFormat(formatDateTemplate)
|
||||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
.format(DateTime.parse(isoDate).toLocal());
|
||||||
var isMultiSelectEnable =
|
var isMultiSelectEnable =
|
||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||||
|
|||||||
@@ -33,34 +33,10 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
var assetType = assetGroup[index].type;
|
var assetType = assetGroup[index].type;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: Stack(
|
child: ThumbnailImage(
|
||||||
children: [
|
asset: assetGroup[index],
|
||||||
ThumbnailImage(
|
assetList: sortedAssetGroup,
|
||||||
asset: assetGroup[index],
|
showStorageIndicator: showStorageIndicator,
|
||||||
assetList: sortedAssetGroup,
|
|
||||||
showStorageIndicator: showStorageIndicator,
|
|
||||||
),
|
|
||||||
if (assetType != AssetTypeEnum.IMAGE)
|
|
||||||
Positioned(
|
|
||||||
top: 5,
|
|
||||||
right: 5,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
assetGroup[index].duration.toString().substring(0, 7),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||||
.format(DateTime.parse(isoDate));
|
.format(DateTime.parse(isoDate).toLocal());
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final bool useGrayBoxPlaceholder;
|
||||||
|
|
||||||
const ThumbnailImage({
|
const ThumbnailImage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.assetList,
|
required this.assetList,
|
||||||
this.showStorageIndicator = true,
|
this.showStorageIndicator = true,
|
||||||
|
this.useGrayBoxPlaceholder = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,13 +104,19 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
},
|
},
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||||
Transform.scale(
|
if (useGrayBoxPlaceholder) {
|
||||||
scale: 0.2,
|
return const DecoratedBox(
|
||||||
child: CircularProgressIndicator(
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
value: downloadProgress.progress,
|
);
|
||||||
),
|
}
|
||||||
),
|
return Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: downloadProgress.progress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
debugPrint("Error getting thumbnail $url = $error");
|
debugPrint("Error getting thumbnail $url = $error");
|
||||||
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
||||||
@@ -139,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
|
if (asset.type != AssetTypeEnum.IMAGE)
|
||||||
|
Positioned(
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
asset.duration.toString().substring(0, 7),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_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';
|
||||||
@@ -25,6 +27,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
var renderList = ref.watch(renderListProvider);
|
||||||
|
|
||||||
ScrollController scrollController = useScrollController();
|
ScrollController scrollController = useScrollController();
|
||||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> imageGridGroup = [];
|
List<Widget> imageGridGroup = [];
|
||||||
@@ -120,6 +124,31 @@ class HomePage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildAssetGrid() {
|
||||||
|
if (appSettingService
|
||||||
|
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
|
||||||
|
return ImmichAssetGrid(
|
||||||
|
renderList: renderList,
|
||||||
|
assetsPerRow:
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
|
showStorageIndicator: appSettingService
|
||||||
|
.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return DraggableScrollbar.semicircle(
|
||||||
|
backgroundColor: Theme.of(context).hintColor,
|
||||||
|
controller: scrollController,
|
||||||
|
heightScrollThumb: 48.0,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
slivers: [
|
||||||
|
...imageGridGroup,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: !isMultiSelectEnable,
|
bottom: !isMultiSelectEnable,
|
||||||
top: !isMultiSelectEnable,
|
top: !isMultiSelectEnable,
|
||||||
@@ -132,17 +161,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||||
child: DraggableScrollbar.semicircle(
|
child: _buildAssetGrid(),
|
||||||
backgroundColor: Theme.of(context).hintColor,
|
|
||||||
controller: scrollController,
|
|
||||||
heightScrollThumb: 48.0,
|
|
||||||
child: CustomScrollView(
|
|
||||||
controller: scrollController,
|
|
||||||
slivers: [
|
|
||||||
...imageGridGroup,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (isMultiSelectEnable) ...[
|
if (isMultiSelectEnable) ...[
|
||||||
_buildSelectedItemCountIndicator(),
|
_buildSelectedItemCountIndicator(),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
|
|||||||
(a, b) => b.compareTo(a),
|
(a, b) => b.compareTo(a),
|
||||||
);
|
);
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('y-MM-dd')
|
||||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
_buildThings() {
|
_buildThings() {
|
||||||
return curatedObjects.when(
|
return curatedObjects.when(
|
||||||
loading: () => const SizedBox(
|
loading: () => SizedBox(
|
||||||
height: 200,
|
height: imageSize,
|
||||||
child: Center(child: ImmichLoadingIndicator()),
|
child: const Center(child: ImmichLoadingIndicator()),
|
||||||
),
|
),
|
||||||
error: (err, stack) => Text('Error: $err'),
|
error: (err, stack) => Text('Error: $err'),
|
||||||
data: (objects) {
|
data: (objects) {
|
||||||
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
// height: imageSize,
|
height: imageSize,
|
||||||
width: imageSize,
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.only(left: 16),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ enum AppSettingsEnum<T> {
|
|||||||
storageIndicator<bool>("storageIndicator", true),
|
storageIndicator<bool>("storageIndicator", true),
|
||||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||||
imageCacheSize<int>("imageCacheSize", 350),
|
imageCacheSize<int>("imageCacheSize", 350),
|
||||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
|
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||||
|
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||||
|
|
||||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.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/ui/immich_toast.dart';
|
||||||
|
|
||||||
|
class ExperimentalSettings extends HookConsumerWidget {
|
||||||
|
const ExperimentalSettings({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final useExperimentalAssetGrid = useState(false);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
useExperimentalAssetGrid.value = appSettingService
|
||||||
|
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
void changeUseExperimentalAssetGrid(bool status) {
|
||||||
|
useExperimentalAssetGrid.value = status;
|
||||||
|
appSettingService.setSetting(
|
||||||
|
AppSettingsEnum.useExperimentalAssetGrid,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "settings_require_restart".tr(),
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExpansionTile(
|
||||||
|
textColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
'experimental_settings_title',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
subtitle: const Text(
|
||||||
|
'experimental_settings_subtitle',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
"experimental_settings_new_asset_list_title",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
subtitle: const Text(
|
||||||
|
"experimental_settings_new_asset_list_subtitle",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
value: useExperimentalAssetGrid.value,
|
||||||
|
onChanged: changeUseExperimentalAssetGrid,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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/asset_list_settings/asset_list_settings.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_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/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';
|
||||||
@@ -42,6 +43,7 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
const ThemeSetting(),
|
const ThemeSetting(),
|
||||||
const AssetListSettings(),
|
const AssetListSettings(),
|
||||||
if (Platform.isAndroid) const NotificationSetting(),
|
if (Platform.isAndroid) const NotificationSetting(),
|
||||||
|
const ExperimentalSettings(),
|
||||||
],
|
],
|
||||||
).toList(),
|
).toList(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
|
|||||||
(a, b) => b.compareTo(a),
|
(a, b) => b.compareTo(a),
|
||||||
);
|
);
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('y-MM-dd')
|
||||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('MMMM, y')
|
||||||
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ImmichToast {
|
|||||||
ToastType toastType = ToastType.info,
|
ToastType toastType = ToastType.info,
|
||||||
ToastGravity gravity = ToastGravity.TOP,
|
ToastGravity gravity = ToastGravity.TOP,
|
||||||
}) {
|
}) {
|
||||||
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
final fToast = FToast();
|
final fToast = FToast();
|
||||||
fToast.init(context);
|
fToast.init(context);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ class ImmichToast {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(5.0),
|
borderRadius: BorderRadius.circular(5.0),
|
||||||
color: Colors.grey[50],
|
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|||||||
@@ -868,6 +868,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.27.3"
|
version: "0.27.3"
|
||||||
|
scrollable_positioned_list:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: scrollable_positioned_list
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.4"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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.29.1+43
|
version: 1.30.2+48
|
||||||
|
|
||||||
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
|
||||||
|
scrollable_positioned_list: ^0.3.4
|
||||||
|
|
||||||
path: ^1.8.1
|
path: ^1.8.1
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
|
|||||||
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -38,4 +38,5 @@ lerna-debug.log*
|
|||||||
dist/
|
dist/
|
||||||
upload/
|
upload/
|
||||||
tmp/
|
tmp/
|
||||||
core
|
core
|
||||||
|
.reverse-geocoding-dump/
|
||||||
|
|||||||
@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
|
|||||||
|
|
||||||
RUN npm prune --production
|
RUN npm prune --production
|
||||||
|
|
||||||
|
VOLUME /usr/src/app/upload
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
.createQueryBuilder('asset')
|
.createQueryBuilder('asset')
|
||||||
.where('asset.userId = :userId', { userId: userId })
|
.where('asset.userId = :userId', { userId: userId })
|
||||||
.andWhere('asset.resizePath is not NULL')
|
.andWhere('asset.resizePath is not NULL')
|
||||||
.andWhere('asset.type = :type', { type: AssetType.IMAGE })
|
|
||||||
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
||||||
.orderBy('asset.createdAt', 'DESC');
|
.orderBy('asset.createdAt', 'DESC');
|
||||||
|
|
||||||
@@ -226,7 +225,6 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
where: {
|
where: {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
type: AssetType.IMAGE,
|
|
||||||
},
|
},
|
||||||
select: ['deviceAssetId'],
|
select: ['deviceAssetId'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class AssetController {
|
|||||||
|
|
||||||
await this.assetUploadedQueue.add(
|
await this.assetUploadedQueue.add(
|
||||||
assetUploadedProcessorName,
|
assetUploadedProcessorName,
|
||||||
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
|
{ asset: savedAsset, fileName: file.originalname },
|
||||||
{ jobId: savedAsset.id },
|
{ jobId: savedAsset.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
Response,
|
Response,
|
||||||
Request,
|
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
@@ -22,7 +21,7 @@ import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
|
|||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
|
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
|
||||||
import { Response as Res, Request as Req } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
import { UserResponseDto } from './response-dto/user-response.dto';
|
import { UserResponseDto } from './response-dto/user-response.dto';
|
||||||
import { UserCountResponseDto } from './response-dto/user-count-response.dto';
|
import { UserCountResponseDto } from './response-dto/user-count-response.dto';
|
||||||
@@ -93,9 +92,7 @@ export class UserController {
|
|||||||
async createProfileImage(
|
async createProfileImage(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@UploadedFile() fileInfo: Express.Multer.File,
|
@UploadedFile() fileInfo: Express.Multer.File,
|
||||||
@Request() req: Req,
|
|
||||||
): Promise<CreateProfileImageResponseDto> {
|
): Promise<CreateProfileImageResponseDto> {
|
||||||
console.log(req.body, req.file);
|
|
||||||
return await this.userService.createProfileImage(authUser, fileInfo);
|
return await this.userService.createProfileImage(authUser, fileInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
|
|||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||||
import { DatabaseModule } from '@app/database';
|
import { DatabaseModule } from '@app/database';
|
||||||
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -65,7 +64,7 @@ export class AppModule implements NestModule {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
if (process.env.NODE_ENV == 'development') {
|
if (process.env.NODE_ENV == 'development') {
|
||||||
consumer.apply(AppLoggerMiddleware).forRoutes('*');
|
// consumer.apply(AppLoggerMiddleware).forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import sanitize from 'sanitize-filename';
|
|||||||
|
|
||||||
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|x-adobe-dng|webp|tiff|3gpp)$/)) {
|
if (
|
||||||
|
file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef)$/)
|
||||||
|
) {
|
||||||
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);
|
||||||
@@ -39,8 +41,8 @@ export const assetUploadOption: MulterOptions = {
|
|||||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||||
const fileNameUUID = randomUUID();
|
const fileNameUUID = randomUUID();
|
||||||
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
||||||
|
const sanitizedFileName = sanitize(fileName);
|
||||||
cb(null, sanitize(fileName));
|
cb(null, sanitizedFileName);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
|||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 29,
|
minor: 30,
|
||||||
patch: 2,
|
patch: 2,
|
||||||
build: 43,
|
build: 48,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import { Queue } from 'bull';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
import {
|
import {
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
generateWEBPThumbnailProcessorName,
|
||||||
IMetadataExtractionJob,
|
IMetadataExtractionJob,
|
||||||
IVideoTranscodeJob,
|
IVideoTranscodeJob,
|
||||||
metadataExtractionQueueName,
|
metadataExtractionQueueName,
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
videoConversionQueueName,
|
|
||||||
generateWEBPThumbnailProcessorName,
|
|
||||||
mp4ConversionProcessorName,
|
mp4ConversionProcessorName,
|
||||||
reverseGeocodingProcessorName,
|
reverseGeocodingProcessorName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
videoConversionQueueName,
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@@ -80,11 +82,11 @@ export class ScheduleTasksService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_5_SECONDS)
|
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||||
async reverseGeocoding() {
|
async reverseGeocoding() {
|
||||||
const isMapboxEnable = this.configService.get('ENABLE_MAPBOX');
|
const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
|
||||||
|
|
||||||
if (isMapboxEnable) {
|
if (isGeocodingEnabled) {
|
||||||
const exifInfo = await this.exifRepository.find({
|
const exifInfo = await this.exifRepository.find({
|
||||||
where: {
|
where: {
|
||||||
city: IsNull(),
|
city: IsNull(),
|
||||||
@@ -94,7 +96,37 @@ export class ScheduleTasksService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const exif of exifInfo) {
|
for (const exif of exifInfo) {
|
||||||
await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() });
|
await this.metadataExtractionQueue.add(
|
||||||
|
reverseGeocodingProcessorName,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
{ exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||||
|
async extractExif() {
|
||||||
|
const exifAssets = await this.assetRepository.find({
|
||||||
|
where: {
|
||||||
|
exifInfo: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const asset of exifAssets) {
|
||||||
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
await this.metadataExtractionQueue.add(
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
{ asset, fileName: asset.id },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.metadataExtractionQueue.add(
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
{ asset, fileName: asset.id },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,18 @@ export class AssetUploadedProcessor {
|
|||||||
*/
|
*/
|
||||||
@Process(assetUploadedProcessorName)
|
@Process(assetUploadedProcessorName)
|
||||||
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
||||||
const { asset, fileName, fileSize } = job.data;
|
const { asset, fileName } = job.data;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
|
||||||
// Video Conversion
|
// Video Conversion
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
|
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
await this.metadataExtractionQueue.add(
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
{ asset, fileName },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||||
await this.metadataExtractionQueue.add(
|
await this.metadataExtractionQueue.add(
|
||||||
@@ -56,19 +61,9 @@ export class AssetUploadedProcessor {
|
|||||||
{
|
{
|
||||||
asset,
|
asset,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize,
|
|
||||||
},
|
},
|
||||||
{ jobId: randomUUID() },
|
{ jobId: randomUUID() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract video duration if uploaded from the web & CLI
|
|
||||||
if (asset.type == AssetType.VIDEO) {
|
|
||||||
await this.metadataExtractionQueue.add(
|
|
||||||
videoMetadataExtractionProcessorName,
|
|
||||||
{ asset, fileName, fileSize },
|
|
||||||
{ jobId: randomUUID() }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
reverseGeocodingProcessorName,
|
reverseGeocodingProcessorName,
|
||||||
IReverseGeocodingProcessor,
|
IReverseGeocodingProcessor,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
|
||||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -26,10 +24,64 @@ import ffmpeg from 'fluent-ffmpeg';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { Repository } from 'typeorm/repository/Repository';
|
import { Repository } from 'typeorm/repository/Repository';
|
||||||
|
import geocoder, { InitOptions } from 'local-reverse-geocoder';
|
||||||
|
import { getName } from 'i18n-iso-countries';
|
||||||
|
import { find } from 'geo-tz';
|
||||||
|
import * as luxon from 'luxon';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
function geocoderInit(init: InitOptions) {
|
||||||
|
return new Promise<void>(function (resolve) {
|
||||||
|
geocoder.init(init, () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function geocoderLookup(points: { latitude: number; longitude: number }[]) {
|
||||||
|
return new Promise<GeoData>(function (resolve) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
geocoder.lookUp(points, 1, (err, addresses) => {
|
||||||
|
resolve(addresses[0][0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
|
||||||
|
|
||||||
|
export interface AdminCode {
|
||||||
|
name: string;
|
||||||
|
asciiName: string;
|
||||||
|
geoNameId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoData {
|
||||||
|
geoNameId: string;
|
||||||
|
name: string;
|
||||||
|
asciiName: string;
|
||||||
|
alternateNames: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
featureClass: string;
|
||||||
|
featureCode: string;
|
||||||
|
countryCode: string;
|
||||||
|
cc2?: any;
|
||||||
|
admin1Code?: AdminCode;
|
||||||
|
admin2Code?: AdminCode;
|
||||||
|
admin3Code?: any;
|
||||||
|
admin4Code?: any;
|
||||||
|
population: string;
|
||||||
|
elevation: string;
|
||||||
|
dem: string;
|
||||||
|
timezone: string;
|
||||||
|
modificationDate: string;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Processor(metadataExtractionQueueName)
|
@Processor(metadataExtractionQueueName)
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
private geocodingClient?: GeocodeService;
|
private isGeocodeInitialized = false;
|
||||||
private logLevel: ImmichLogLevel;
|
private logLevel: ImmichLogLevel;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -44,19 +96,52 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
|
if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
|
||||||
this.geocodingClient = mapboxGeocoding({
|
Logger.log('Initialising Reverse Geocoding');
|
||||||
accessToken: process.env.MAPBOX_KEY,
|
geocoderInit({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
citiesFileOverride: geocodingPrecisionLevels[configService.get('REVERSE_GEOCODING_PRECISION')],
|
||||||
|
load: {
|
||||||
|
admin1: true,
|
||||||
|
admin2: true,
|
||||||
|
admin3And4: false,
|
||||||
|
alternateNames: false,
|
||||||
|
},
|
||||||
|
countries: [],
|
||||||
|
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
|
||||||
|
}).then(() => {
|
||||||
|
this.isGeocodeInitialized = true;
|
||||||
|
Logger.log('Reverse Geocoding Initialised');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async reverseGeocodeExif(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
): Promise<{ country: string; state: string; city: string }> {
|
||||||
|
const geoCodeInfo = await geocoderLookup([{ latitude, longitude }]);
|
||||||
|
|
||||||
|
const country = getName(geoCodeInfo.countryCode, 'en');
|
||||||
|
const city = geoCodeInfo.name;
|
||||||
|
|
||||||
|
let state = '';
|
||||||
|
if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
|
||||||
|
if (geoCodeInfo.admin1Code?.name) {
|
||||||
|
if (geoCodeInfo.admin2Code?.name) state += ', ';
|
||||||
|
state += geoCodeInfo.admin1Code.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { country, state, city };
|
||||||
|
}
|
||||||
|
|
||||||
@Process(exifExtractionProcessorName)
|
@Process(exifExtractionProcessorName)
|
||||||
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
|
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
|
||||||
try {
|
try {
|
||||||
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
|
const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
|
||||||
const exifData = await exifr.parse(asset.originalPath, {
|
const exifData = await exifr.parse(asset.originalPath, {
|
||||||
tiff: true,
|
tiff: true,
|
||||||
ifd0: true as any,
|
ifd0: true as any,
|
||||||
@@ -75,6 +160,11 @@ export class MetadataExtractionProcessor {
|
|||||||
throw new Error(`can not parse exif data from file ${asset.originalPath}`);
|
throw new Error(`can not parse exif data from file ${asset.originalPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(exifData.DateTimeOriginal || exifData.CreateDate || new Date(asset.createdAt));
|
||||||
|
|
||||||
|
const fileStats = fs.statSync(asset.originalPath);
|
||||||
|
const fileSizeInBytes = fileStats.size;
|
||||||
|
|
||||||
const newExif = new ExifEntity();
|
const newExif = new ExifEntity();
|
||||||
newExif.assetId = asset.id;
|
newExif.assetId = asset.id;
|
||||||
newExif.make = exifData['Make'] || null;
|
newExif.make = exifData['Make'] || null;
|
||||||
@@ -82,9 +172,9 @@ export class MetadataExtractionProcessor {
|
|||||||
newExif.imageName = path.parse(fileName).name || null;
|
newExif.imageName = path.parse(fileName).name || null;
|
||||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
|
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
|
||||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
|
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
|
||||||
newExif.fileSizeInByte = fileSize || null;
|
newExif.fileSizeInByte = fileSizeInBytes || null;
|
||||||
newExif.orientation = exifData['Orientation'] || null;
|
newExif.orientation = exifData['Orientation'] || null;
|
||||||
newExif.dateTimeOriginal = new Date(asset.createdAt) || null;
|
newExif.dateTimeOriginal = createdAt;
|
||||||
newExif.modifyDate = exifData['ModifyDate'] || null;
|
newExif.modifyDate = exifData['ModifyDate'] || null;
|
||||||
newExif.lensModel = exifData['LensModel'] || null;
|
newExif.lensModel = exifData['LensModel'] || null;
|
||||||
newExif.fNumber = exifData['FNumber'] || null;
|
newExif.fNumber = exifData['FNumber'] || null;
|
||||||
@@ -94,39 +184,60 @@ export class MetadataExtractionProcessor {
|
|||||||
newExif.latitude = exifData['latitude'] || null;
|
newExif.latitude = exifData['latitude'] || null;
|
||||||
newExif.longitude = exifData['longitude'] || null;
|
newExif.longitude = exifData['longitude'] || null;
|
||||||
|
|
||||||
// Reverse GeoCoding
|
/**
|
||||||
if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) {
|
* Correctly store UTC time based on timezone
|
||||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
* The timestamp being extracted from EXIF is based on the timezone
|
||||||
.reverseGeocode({
|
* of the container. We need to correct it to UTC time based on the
|
||||||
query: [exifData['longitude'], exifData['latitude']],
|
* timezone of the location.
|
||||||
types: ['country', 'region', 'place'],
|
*
|
||||||
})
|
* The timezone of the location can be exracted from the lat/lon
|
||||||
.send();
|
* GPS coordinates.
|
||||||
|
*
|
||||||
|
* Any assets that doesn't have this information will used the
|
||||||
|
* createdAt timestamp of the asset instead.
|
||||||
|
*
|
||||||
|
* The updated/corrected timestamp will be used to update the
|
||||||
|
* createdAt timestamp in the asset table. So that the information
|
||||||
|
* is consistent across the database.
|
||||||
|
* */
|
||||||
|
if (newExif.longitude && newExif.latitude) {
|
||||||
|
const tz = find(newExif.latitude, newExif.longitude)[0];
|
||||||
|
const localTimeWithTimezone = createdAt.toISOString();
|
||||||
|
|
||||||
const res: [] = geoCodeInfo.body['features'];
|
if (localTimeWithTimezone.length == 24) {
|
||||||
|
// Remove the last character
|
||||||
let city = '';
|
const localTimeWithoutTimezone = localTimeWithTimezone.slice(0, -1);
|
||||||
let state = '';
|
const correctUTCTime = luxon.DateTime.fromISO(localTimeWithoutTimezone, { zone: tz }).toUTC().toISO();
|
||||||
let country = '';
|
newExif.dateTimeOriginal = new Date(correctUTCTime);
|
||||||
|
await this.assetRepository.save({
|
||||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
id: asset.id,
|
||||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
createdAt: correctUTCTime,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
await this.assetRepository.save({
|
||||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
id: asset.id,
|
||||||
}
|
createdAt: createdAt.toISOString(),
|
||||||
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich metadata
|
/**
|
||||||
|
* Reverse Geocoding
|
||||||
|
*
|
||||||
|
* Get the city, state or region name of the asset
|
||||||
|
* based on lat/lon GPS coordinates.
|
||||||
|
*/
|
||||||
|
if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
|
||||||
|
const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
|
||||||
|
newExif.country = country;
|
||||||
|
newExif.state = state;
|
||||||
|
newExif.city = city;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IF the EXIF doesn't contain the width and height of the image,
|
||||||
|
* We will use Sharpjs to get the information.
|
||||||
|
*/
|
||||||
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
||||||
const metadata = await sharp(asset.originalPath).metadata();
|
const metadata = await sharp(asset.originalPath).metadata();
|
||||||
|
|
||||||
@@ -155,35 +266,10 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
@Process({ name: reverseGeocodingProcessorName })
|
@Process({ name: reverseGeocodingProcessorName })
|
||||||
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
|
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
|
||||||
const { exif } = job.data;
|
if (this.isGeocodeInitialized) {
|
||||||
|
const { latitude, longitude } = job.data;
|
||||||
if (this.geocodingClient) {
|
const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
|
||||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
|
||||||
.reverseGeocode({
|
|
||||||
query: [Number(exif.longitude), Number(exif.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'];
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,35 +381,11 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reverse GeoCoding
|
// Reverse GeoCoding
|
||||||
if (this.geocodingClient && newExif.longitude && newExif.latitude) {
|
if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
|
||||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
|
||||||
.reverseGeocode({
|
newExif.country = country;
|
||||||
query: [newExif.longitude, newExif.latitude],
|
newExif.state = state;
|
||||||
types: ['country', 'region', 'place'],
|
newExif.city = city;
|
||||||
})
|
|
||||||
.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) {
|
for (const stream of data.streams) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
|
|
||||||
const temp = asset.originalPath.split('/');
|
const temp = asset.originalPath.split('/');
|
||||||
const originalFilename = temp[temp.length - 1].split('.')[0];
|
const originalFilename = temp[temp.length - 1].split('.')[0];
|
||||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
|
||||||
|
|
||||||
if (asset.type == AssetType.IMAGE) {
|
if (asset.type == AssetType.IMAGE) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ export const immichAppConfig: ConfigModuleOptions = {
|
|||||||
DB_PASSWORD: Joi.string().required(),
|
DB_PASSWORD: Joi.string().required(),
|
||||||
DB_DATABASE_NAME: Joi.string().required(),
|
DB_DATABASE_NAME: Joi.string().required(),
|
||||||
JWT_SECRET: Joi.string().required(),
|
JWT_SECRET: Joi.string().required(),
|
||||||
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
|
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||||
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
|
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
|
||||||
is: false,
|
|
||||||
then: Joi.string().optional().allow(null, ''),
|
|
||||||
otherwise: Joi.string().required(),
|
|
||||||
}),
|
|
||||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,4 @@ export interface IAssetUploadedJob {
|
|||||||
* Original file name
|
* Original file name
|
||||||
*/
|
*/
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* File size in byte
|
|
||||||
*/
|
|
||||||
fileSize: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
|
||||||
|
|
||||||
export interface IExifExtractionProcessor {
|
export interface IExifExtractionProcessor {
|
||||||
/**
|
/**
|
||||||
@@ -11,11 +10,6 @@ export interface IExifExtractionProcessor {
|
|||||||
* Original file name
|
* Original file name
|
||||||
*/
|
*/
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* File size in byte
|
|
||||||
*/
|
|
||||||
fileSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVideoLengthExtractionProcessor {
|
export interface IVideoLengthExtractionProcessor {
|
||||||
@@ -28,18 +22,12 @@ export interface IVideoLengthExtractionProcessor {
|
|||||||
* Original file name
|
* Original file name
|
||||||
*/
|
*/
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* File size in byte
|
|
||||||
*/
|
|
||||||
fileSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReverseGeocodingProcessor {
|
export interface IReverseGeocodingProcessor {
|
||||||
/**
|
exifId: string;
|
||||||
* The Asset entity that was saved in the database
|
latitude: number;
|
||||||
*/
|
longitude: number;
|
||||||
exif: ExifEntity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IMetadataExtractionJob =
|
export type IMetadataExtractionJob =
|
||||||
|
|||||||
2301
server/package-lock.json
generated
2301
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@
|
|||||||
"api:generate": "npm run api:typescript && npm run api:dart"
|
"api:generate": "npm run api:typescript && npm run api:dart"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/mapbox-sdk": "^0.13.3",
|
|
||||||
"@nestjs/bull": "^0.5.5",
|
"@nestjs/bull": "^0.5.5",
|
||||||
"@nestjs/common": "^8.4.7",
|
"@nestjs/common": "^8.4.7",
|
||||||
"@nestjs/config": "^2.1.0",
|
"@nestjs/config": "^2.1.0",
|
||||||
@@ -53,8 +52,12 @@
|
|||||||
"dotenv": "^14.2.0",
|
"dotenv": "^14.2.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
|
"geo-tz": "^7.0.2",
|
||||||
|
"i18n-iso-countries": "^7.5.0",
|
||||||
"joi": "^17.5.0",
|
"joi": "^17.5.0",
|
||||||
|
"local-reverse-geocoder": "^0.12.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.0.3",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
@@ -83,7 +86,6 @@
|
|||||||
"@types/imagemin": "^8.0.0",
|
"@types/imagemin": "^8.0.0",
|
||||||
"@types/jest": "27.0.2",
|
"@types/jest": "27.0.2",
|
||||||
"@types/lodash": "^4.14.178",
|
"@types/lodash": "^4.14.178",
|
||||||
"@types/mapbox__mapbox-sdk": "^0.13.4",
|
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/passport-jwt": "^3.0.6",
|
"@types/passport-jwt": "^3.0.6",
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
|
||||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||||
}`}
|
}`}
|
||||||
on:mouseenter={() => {
|
on:mouseenter={() => {
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
|
||||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||||
}`}
|
}`}
|
||||||
on:click={navigateAssetForward}
|
on:click={navigateAssetForward}
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
|
||||||
class:navigation-button-hover={halfRightHover}
|
class:navigation-button-hover={halfRightHover}
|
||||||
on:click={navigateAssetForward}
|
on:click={navigateAssetForward}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { env } from '$env/dynamic/public';
|
|
||||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||||
|
|
||||||
type Leaflet = typeof import('leaflet');
|
type Leaflet = typeof import('leaflet');
|
||||||
@@ -31,13 +30,6 @@
|
|||||||
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
||||||
await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
|
await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove timezone when user not config PUBLIC_TZ var. Etc/UTC is used in default.
|
|
||||||
if (asset.exifInfo?.dateTimeOriginal && !env.PUBLIC_TZ) {
|
|
||||||
const dateTimeOriginal = asset.exifInfo.dateTimeOriginal;
|
|
||||||
|
|
||||||
asset.exifInfo.dateTimeOriginal = dateTimeOriginal.slice(0, dateTimeOriginal.length - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
|
|||||||
|
|
||||||
fileSelector.type = 'file';
|
fileSelector.type = 'file';
|
||||||
fileSelector.multiple = true;
|
fileSelector.multiple = true;
|
||||||
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp';
|
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp,.nef';
|
||||||
|
|
||||||
fileSelector.onchange = async (e: Event) => {
|
fileSelector.onchange = async (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ parent }) => {
|
|||||||
user
|
user
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.log('Photo page load error', e);
|
||||||
throw redirect(302, '/auth/login');
|
throw redirect(302, '/auth/login');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user