From 0020c7be94d0096c0c0ec1574a7905fed44fcaa4 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 01:06:35 -0600 Subject: [PATCH 01/13] Implementing video upload features --- Makefile | 4 +- mobile/lib/modules/home/views/home_page.dart | 79 +++++++------------- mobile/lib/modules/login/ui/login_form.dart | 2 +- 3 files changed, 31 insertions(+), 54 deletions(-) diff --git a/Makefile b/Makefile index e3f4b19943a72..33ddf504b802b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -run_server_dev: +dev: docker-compose -f ./server/docker-compose.yml up -run_server_update: +update: docker-compose -f ./server/docker-compose.yml up --build -V \ No newline at end of file diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 4ef968870e200..90c6b0c3b440d 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -18,7 +18,7 @@ class HomePage extends HookConsumerWidget { ScrollController _scrollController = useScrollController(); List assetGroup = ref.watch(assetProvider); List imageGridGroup = []; - final scrollLabelText = useState(""); + String scrollBarText = ""; _scrollControllerCallback() { var endOfPage = _scrollController.position.maxScrollExtent; @@ -45,34 +45,6 @@ class HomePage extends HookConsumerWidget { }; }, []); - SliverToBoxAdapter _buildDateGroupTitle(String dateTitle) { - var currentYear = DateTime.now().year; - var groupYear = DateTime.parse(dateTitle).year; - var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; - var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(dateTitle)); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0), - child: Text( - dateText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ); - } - Widget _buildBody() { if (assetGroup.isNotEmpty) { String lastGroupDate = assetGroup[0].date; @@ -86,16 +58,14 @@ class HomePage extends HookConsumerWidget { // Add Monthly Title Group if started at the beginning of the month if ((currentMonth! - previousMonth!) != 0) { - var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(dateTitle)); - imageGridGroup.add( - MonthlyTitleText(monthTitleText: monthTitleText), + MonthlyTitleText(isoDate: dateTitle), ); } // Add Daily Title Group imageGridGroup.add( - DailyTitleText(dateTitle: dateTitle), + DailyTitleText(isoDate: dateTitle), ); // Add Image Group @@ -110,18 +80,23 @@ class HomePage extends HookConsumerWidget { return SafeArea( child: DraggableScrollbar.semicircle( // labelTextBuilder: (offset) { - // final int currentItem = _scrollController.hasClients - // ? (_scrollController.offset / _scrollController.position.maxScrollExtent * imageGridGroup.length) - // .floor() - // : 0; - - // if (imageGridGroup[currentItem] is MonthlyTitleText) { - // MonthlyTitleText item = imageGridGroup[currentItem] as MonthlyTitleText; - - // scrollLabelText.value = item.monthTitleText; - // } - - // return Text(scrollLabelText.value); + // final int currentItem = _scrollController.hasClients + // ? (_scrollController.offset / _scrollController.position.maxScrollExtent * imageGridGroup.length) + // .floor() + // : 0; + + // if (imageGridGroup[currentItem] is DailyTitleText) { + // DailyTitleText item = imageGridGroup[currentItem] as DailyTitleText; + // debugPrint(item.isoDate); + // return const Text(""); + // } + + // if (imageGridGroup[currentItem] is MonthlyTitleText) { + // MonthlyTitleText item = imageGridGroup[currentItem] as MonthlyTitleText; + // debugPrint(item.isoDate); + // return const Text("scrollBarText"); + // } + // return const Text("scrollBarText"); // }, // labelConstraints: const BoxConstraints.tightFor(width: 200.0, height: 30.0), backgroundColor: Theme.of(context).primaryColor, @@ -148,13 +123,15 @@ class HomePage extends HookConsumerWidget { class MonthlyTitleText extends StatelessWidget { const MonthlyTitleText({ Key? key, - required this.monthTitleText, + required this.isoDate, }) : super(key: key); - final String monthTitleText; + final String isoDate; @override Widget build(BuildContext context) { + var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate)); + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 10.0, top: 32), @@ -174,17 +151,17 @@ class MonthlyTitleText extends StatelessWidget { class DailyTitleText extends StatelessWidget { const DailyTitleText({ Key? key, - required this.dateTitle, + required this.isoDate, }) : super(key: key); - final String dateTitle; + final String isoDate; @override Widget build(BuildContext context) { var currentYear = DateTime.now().year; - var groupYear = DateTime.parse(dateTitle).year; + var groupYear = DateTime.parse(isoDate).year; var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; - var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(dateTitle)); + var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); return SliverToBoxAdapter( child: Padding( diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 47c8f66e6140a..b99aaefe8e019 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -13,7 +13,7 @@ class LoginForm extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final usernameController = useTextEditingController(text: 'testuser@email.com'); final passwordController = useTextEditingController(text: 'password'); - final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:3000'); + final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:3000'); return Center( child: ConstrainedBox( From 168676075ff36d070b441b10d713ca535d910a53 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 01:34:23 -0600 Subject: [PATCH 02/13] setup image resize processor --- Makefile | 2 +- .../lib/shared/providers/backup.provider.dart | 4 +- .../lib/shared/services/backup.service.dart | 1 - server/src/api-v1/asset/asset.controller.ts | 10 ++-- server/src/api-v1/asset/asset.module.ts | 6 +-- .../image-optimize/image-optimize.module.ts | 8 ++-- .../image-optimize.processor.ts | 48 +++++++++++++++++-- .../image-optimize/image-optimize.service.ts | 22 +++++++-- 8 files changed, 80 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 33ddf504b802b..033ffd6196676 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ dev: docker-compose -f ./server/docker-compose.yml up -update: +dev-update: docker-compose -f ./server/docker-compose.yml up --build -V \ No newline at end of file diff --git a/mobile/lib/shared/providers/backup.provider.dart b/mobile/lib/shared/providers/backup.provider.dart index e17466e053815..61477e79b5ca1 100644 --- a/mobile/lib/shared/providers/backup.provider.dart +++ b/mobile/lib/shared/providers/backup.provider.dart @@ -35,7 +35,7 @@ class BackupNotifier extends StateNotifier { void getBackupInfo() async { _updateServerInfo(); - List list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.image); + List list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common); if (list.isEmpty) { debugPrint("No Asset On Device"); @@ -59,7 +59,7 @@ class BackupNotifier extends StateNotifier { // await PhotoManager.presentLimited(); // Gather assets info List list = - await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.image); + await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); if (list.isEmpty) { debugPrint("No Asset On Device - Abort Backup Process"); diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index 0058dfadef409..5ecdd4d103fbd 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/utils/files_helper.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart' as p; -import 'package:exif/exif.dart'; class BackupService { final NetworkService _networkService = NetworkService(); diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 9acb48b2875b8..ff6c0daafc3ae 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -22,7 +22,7 @@ import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { CreateAssetDto } from './dto/create-asset.dto'; import { createReadStream } from 'fs'; import { ServeFileDto } from './dto/serve-file.dto'; -import { ImageOptimizeService } from '../../modules/image-optimize/image-optimize.service'; +import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetType } from './entities/asset.entity'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; @@ -31,7 +31,7 @@ import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; export class AssetController { constructor( private readonly assetService: AssetService, - private readonly imageOptimizeService: ImageOptimizeService, + private readonly assetOptimizeService: AssetOptimizeService, ) {} @Post('upload') @@ -45,7 +45,11 @@ export class AssetController { const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); if (savedAsset && savedAsset.type == AssetType.IMAGE) { - await this.imageOptimizeService.resizeImage(savedAsset); + await this.assetOptimizeService.resizeImage(savedAsset); + } + + if (savedAsset && savedAsset.type == AssetType.IMAGE) { + await this.assetOptimizeService.resizeVideo(savedAsset); } }); diff --git a/server/src/api-v1/asset/asset.module.ts b/server/src/api-v1/asset/asset.module.ts index 2665845090e40..88e1a28e1d34b 100644 --- a/server/src/api-v1/asset/asset.module.ts +++ b/server/src/api-v1/asset/asset.module.ts @@ -4,13 +4,13 @@ import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetEntity } from './entities/asset.entity'; import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module'; -import { ImageOptimizeService } from '../../modules/image-optimize/image-optimize.service'; +import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { BullModule } from '@nestjs/bull'; @Module({ imports: [ BullModule.registerQueue({ - name: 'image', + name: 'optimize', defaultJobOptions: { attempts: 3, removeOnComplete: true, @@ -29,7 +29,7 @@ import { BullModule } from '@nestjs/bull'; ImageOptimizeModule, ], controllers: [AssetController], - providers: [AssetService, ImageOptimizeService], + providers: [AssetService, AssetOptimizeService], exports: [], }) export class AssetModule {} diff --git a/server/src/modules/image-optimize/image-optimize.module.ts b/server/src/modules/image-optimize/image-optimize.module.ts index 2b0222ec7f1fb..5d378e3aa0ff6 100644 --- a/server/src/modules/image-optimize/image-optimize.module.ts +++ b/server/src/modules/image-optimize/image-optimize.module.ts @@ -6,13 +6,13 @@ import { AssetModule } from '../../api-v1/asset/asset.module'; import { AssetService } from '../../api-v1/asset/asset.service'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { ImageOptimizeProcessor } from './image-optimize.processor'; -import { ImageOptimizeService } from './image-optimize.service'; +import { AssetOptimizeService } from './image-optimize.service'; import { MachineLearningProcessor } from './machine-learning.processor'; @Module({ imports: [ BullModule.registerQueue({ - name: 'image', + name: 'optimize', defaultJobOptions: { attempts: 3, removeOnComplete: true, @@ -30,7 +30,7 @@ import { MachineLearningProcessor } from './machine-learning.processor'; TypeOrmModule.forFeature([AssetEntity]), ], - providers: [ImageOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], - exports: [ImageOptimizeService], + providers: [AssetOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], + exports: [AssetOptimizeService], }) export class ImageOptimizeModule {} diff --git a/server/src/modules/image-optimize/image-optimize.processor.ts b/server/src/modules/image-optimize/image-optimize.processor.ts index 87fac6aedb4a2..2f3496d13e45f 100644 --- a/server/src/modules/image-optimize/image-optimize.processor.ts +++ b/server/src/modules/image-optimize/image-optimize.processor.ts @@ -8,7 +8,7 @@ import fs, { existsSync, mkdirSync } from 'fs'; import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'crypto'; -@Processor('image') +@Processor('optimize') export class ImageOptimizeProcessor { constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @@ -16,8 +16,8 @@ export class ImageOptimizeProcessor { private configService: ConfigService, ) {} - @Process('optimize') - async handleOptimization(job: Job) { + @Process('resize-image') + async resizeUploadedImage(job: Job) { const { savedAsset }: { savedAsset: AssetEntity } = job.data; const basePath = this.configService.get('UPLOAD_LOCATION'); @@ -58,4 +58,46 @@ export class ImageOptimizeProcessor { return 'ok'; } + + @Process('resize-video') + async resizeUploadedVideo(job: Job) { + const { savedAsset }: { savedAsset: AssetEntity } = job.data; + + const basePath = this.configService.get('UPLOAD_LOCATION'); + const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); + + // Create folder for thumb image if not exist + const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; + + if (!existsSync(resizeDir)) { + mkdirSync(resizeDir, { recursive: true }); + } + + fs.readFile(savedAsset.originalPath, (err, data) => { + if (err) { + console.error('Error Reading File'); + } + + sharp(data) + .resize(512, 512, { fit: 'outside' }) + .toFile(resizePath, async (err, info) => { + if (err) { + console.error('Error resizing file ', err); + } + + await this.assetRepository.update(savedAsset, { resizePath: resizePath }); + + // Send file to object detection after resizing + // const detectionJob = await this.machineLearningQueue.add( + // 'object-detection', + // { + // resizePath, + // }, + // { jobId: randomUUID() }, + // ); + }); + }); + + return 'ok'; + } } diff --git a/server/src/modules/image-optimize/image-optimize.service.ts b/server/src/modules/image-optimize/image-optimize.service.ts index 3425980baf4c2..4824e9ffd6bb8 100644 --- a/server/src/modules/image-optimize/image-optimize.service.ts +++ b/server/src/modules/image-optimize/image-optimize.service.ts @@ -7,12 +7,26 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; @Injectable() -export class ImageOptimizeService { - constructor(@InjectQueue('image') private imageQueue: Queue) {} +export class AssetOptimizeService { + constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {} public async resizeImage(savedAsset: AssetEntity) { - const job = await this.imageQueue.add( - 'optimize', + const job = await this.optimizeQueue.add( + 'resize-image', + { + savedAsset, + }, + { jobId: randomUUID() }, + ); + + return { + jobId: job.id, + }; + } + + public async resizeVideo(savedAsset: AssetEntity) { + const job = await this.optimizeQueue.add( + 'resize-video', { savedAsset, }, From 69ed2879747cd01c22461e3cc24f5d12226e22cb Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 15:22:10 -0600 Subject: [PATCH 03/13] Add video thumbnail with duration and icon --- mobile/lib/modules/home/ui/image_grid.dart | 127 ++++++++++-------- .../lib/modules/home/ui/thumbnail_image.dart | 7 +- mobile/lib/modules/home/views/home_page.dart | 27 +--- .../lib/shared/models/immich_asset.model.dart | 18 +-- .../lib/shared/services/backup.service.dart | 5 +- server/src/api-v1/asset/asset.controller.ts | 2 +- server/src/api-v1/asset/asset.service.ts | 6 +- .../src/api-v1/asset/dto/create-asset.dto.ts | 5 +- .../src/api-v1/asset/entities/asset.entity.ts | 10 +- .../image-optimize.processor.ts | 28 ++-- .../image-optimize/image-optimize.service.ts | 7 +- 11 files changed, 117 insertions(+), 125 deletions(-) diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index 470520aa306d6..18238d8779678 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/mobile/lib/modules/home/ui/image_grid.dart @@ -1,16 +1,15 @@ -import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; -import 'package:video_player/video_player.dart'; -class ImageGrid extends StatelessWidget { +class ImageGrid extends ConsumerWidget { final List assetGroup; const ImageGrid({Key? key, required this.assetGroup}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5), @@ -19,11 +18,33 @@ class ImageGrid extends StatelessWidget { var assetType = assetGroup[index].type; return GestureDetector( - onTap: () {}, - child: assetType == 'IMAGE' - ? ThumbnailImage(asset: assetGroup[index]) - : VideoThumbnailPlayer(key: Key(assetGroup[index].id), videoAsset: assetGroup[index]), - ); + onTap: () {}, + child: Stack( + children: [ + ThumbnailImage(asset: assetGroup[index]), + assetType == 'IMAGE' + ? Container() + : 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, + ), + ], + ), + ) + ], + )); }, childCount: assetGroup.length, ), @@ -31,55 +52,55 @@ class ImageGrid extends StatelessWidget { } } -class VideoThumbnailPlayer extends StatefulWidget { - ImmichAsset videoAsset; +// class VideoThumbnailPlayer extends StatefulWidget { +// ImmichAsset videoAsset; - VideoThumbnailPlayer({Key? key, required this.videoAsset}) : super(key: key); +// VideoThumbnailPlayer({Key? key, required this.videoAsset}) : super(key: key); - @override - State createState() => _VideoThumbnailPlayerState(); -} +// @override +// State createState() => _VideoThumbnailPlayerState(); +// } -class _VideoThumbnailPlayerState extends State { - late VideoPlayerController videoPlayerController; - ChewieController? chewieController; +// class _VideoThumbnailPlayerState extends State { +// late VideoPlayerController videoPlayerController; +// ChewieController? chewieController; - @override - void initState() { - super.initState(); - initializePlayer(); - } +// @override +// void initState() { +// super.initState(); +// initializePlayer(); +// } - Future initializePlayer() async { - videoPlayerController = - VideoPlayerController.network('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'); +// Future initializePlayer() async { +// videoPlayerController = +// VideoPlayerController.network('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'); - await Future.wait([ - videoPlayerController.initialize(), - ]); - _createChewieController(); - setState(() {}); - } +// await Future.wait([ +// videoPlayerController.initialize(), +// ]); +// _createChewieController(); +// setState(() {}); +// } - _createChewieController() { - chewieController = ChewieController( - showControlsOnInitialize: false, - videoPlayerController: videoPlayerController, - autoPlay: true, - looping: true, - ); - } +// _createChewieController() { +// chewieController = ChewieController( +// showControlsOnInitialize: false, +// videoPlayerController: videoPlayerController, +// autoPlay: true, +// looping: true, +// ); +// } - @override - Widget build(BuildContext context) { - return chewieController != null && chewieController!.videoPlayerController.value.isInitialized - ? SizedBox( - height: 300, - width: 300, - child: Chewie( - controller: chewieController!, - ), - ) - : const Text("Loading Video"); - } -} +// @override +// Widget build(BuildContext context) { +// return chewieController != null && chewieController!.videoPlayerController.value.isInitialized +// ? SizedBox( +// height: 300, +// width: 300, +// child: Chewie( +// controller: chewieController!, +// ), +// ) +// : const Text("Loading Video"); +// } +// } diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index ca8ae1d6a7cbe..1225fd888db2c 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -1,18 +1,21 @@ import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/routing/router.dart'; -class ThumbnailImage extends StatelessWidget { +class ThumbnailImage extends HookWidget { final ImmichAsset asset; const ThumbnailImage({Key? key, required this.asset}) : super(key: key); @override Widget build(BuildContext context) { + final cacheKey = useState(1); + var box = Hive.box(userInfoBox); var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; @@ -31,6 +34,7 @@ class ThumbnailImage extends StatelessWidget { child: Hero( tag: asset.id, child: CachedNetworkImage( + cacheKey: "${asset.id}-${cacheKey.value}", width: 300, height: 300, memCacheHeight: 250, @@ -44,6 +48,7 @@ class ThumbnailImage extends StatelessWidget { ), errorWidget: (context, url, error) { debugPrint("Error Loading Thumbnail Widget $error"); + cacheKey.value += 1; return const Icon(Icons.error); }, ), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 90c6b0c3b440d..7978d4c73217f 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; -import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:intl/intl.dart'; @@ -16,9 +16,9 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final ValueNotifier _showBackToTopBtn = useState(false); ScrollController _scrollController = useScrollController(); + List assetGroup = ref.watch(assetProvider); List imageGridGroup = []; - String scrollBarText = ""; _scrollControllerCallback() { var endOfPage = _scrollController.position.maxScrollExtent; @@ -40,7 +40,6 @@ class HomePage extends HookConsumerWidget { _scrollController.addListener(_scrollControllerCallback); return () { - debugPrint("Remove scroll listener"); _scrollController.removeListener(_scrollControllerCallback); }; }, []); @@ -72,33 +71,13 @@ class HomePage extends HookConsumerWidget { imageGridGroup.add( ImageGrid(assetGroup: assetGroup), ); - + // lastGroupDate = dateTitle; } } return SafeArea( child: DraggableScrollbar.semicircle( - // labelTextBuilder: (offset) { - // final int currentItem = _scrollController.hasClients - // ? (_scrollController.offset / _scrollController.position.maxScrollExtent * imageGridGroup.length) - // .floor() - // : 0; - - // if (imageGridGroup[currentItem] is DailyTitleText) { - // DailyTitleText item = imageGridGroup[currentItem] as DailyTitleText; - // debugPrint(item.isoDate); - // return const Text(""); - // } - - // if (imageGridGroup[currentItem] is MonthlyTitleText) { - // MonthlyTitleText item = imageGridGroup[currentItem] as MonthlyTitleText; - // debugPrint(item.isoDate); - // return const Text("scrollBarText"); - // } - // return const Text("scrollBarText"); - // }, - // labelConstraints: const BoxConstraints.tightFor(width: 200.0, height: 30.0), backgroundColor: Theme.of(context).primaryColor, controller: _scrollController, heightScrollThumb: 48.0, diff --git a/mobile/lib/shared/models/immich_asset.model.dart b/mobile/lib/shared/models/immich_asset.model.dart index 8dad845c24c31..0073dfccc76cc 100644 --- a/mobile/lib/shared/models/immich_asset.model.dart +++ b/mobile/lib/shared/models/immich_asset.model.dart @@ -9,7 +9,7 @@ class ImmichAsset { final String createdAt; final String modifiedAt; final bool isFavorite; - final String? description; + final String? duration; ImmichAsset({ required this.id, @@ -20,7 +20,7 @@ class ImmichAsset { required this.createdAt, required this.modifiedAt, required this.isFavorite, - this.description, + this.duration, }); ImmichAsset copyWith({ @@ -32,7 +32,7 @@ class ImmichAsset { String? createdAt, String? modifiedAt, bool? isFavorite, - String? description, + String? duration, }) { return ImmichAsset( id: id ?? this.id, @@ -43,7 +43,7 @@ class ImmichAsset { createdAt: createdAt ?? this.createdAt, modifiedAt: modifiedAt ?? this.modifiedAt, isFavorite: isFavorite ?? this.isFavorite, - description: description ?? this.description, + duration: duration ?? this.duration, ); } @@ -57,7 +57,7 @@ class ImmichAsset { 'createdAt': createdAt, 'modifiedAt': modifiedAt, 'isFavorite': isFavorite, - 'description': description, + 'duration': duration, }; } @@ -71,7 +71,7 @@ class ImmichAsset { createdAt: map['createdAt'] ?? '', modifiedAt: map['modifiedAt'] ?? '', isFavorite: map['isFavorite'] ?? false, - description: map['description'], + duration: map['duration'], ); } @@ -81,7 +81,7 @@ class ImmichAsset { @override String toString() { - return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, description: $description)'; + return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)'; } @override @@ -97,7 +97,7 @@ class ImmichAsset { other.createdAt == createdAt && other.modifiedAt == modifiedAt && other.isFavorite == isFavorite && - other.description == description; + other.duration == duration; } @override @@ -110,6 +110,6 @@ class ImmichAsset { createdAt.hashCode ^ modifiedAt.hashCode ^ isFavorite.hashCode ^ - description.hashCode; + duration.hashCode; } } diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index 5ecdd4d103fbd..3fb1975712439 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -49,8 +49,8 @@ class BackupService { String originalFileName = await entity.titleAsync; String fileNameWithoutPath = originalFileName.toString().split(".")[0]; var fileExtension = p.extension(file.path); - LatLng coordinate = await entity.latlngAsync(); var mimeType = FileHelper.getMimeType(file.path); + var formData = FormData.fromMap({ 'deviceAssetId': entity.id, 'deviceId': deviceId, @@ -59,8 +59,7 @@ class BackupService { 'modifiedAt': entity.modifiedDateTime.toIso8601String(), 'isFavorite': entity.isFavorite, 'fileExtension': fileExtension, - 'lat': coordinate.latitude, - 'lon': coordinate.longitude, + 'duration': entity.videoDuration, 'files': [ await MultipartFile.fromFile( file.path, diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 6df61a422b947..116f4fb3e306d 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -49,7 +49,7 @@ export class AssetController { } if (savedAsset && savedAsset.type == AssetType.VIDEO) { - await this.assetOptimizeService.resizeVideo(savedAsset); + await this.assetOptimizeService.getVideoThumbnail(savedAsset, file.originalname); } }); diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 8c8532c3c9421..47f41bee993b9 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -26,9 +26,9 @@ export class AssetService { asset.createdAt = assetInfo.createdAt; asset.modifiedAt = assetInfo.modifiedAt; asset.isFavorite = assetInfo.isFavorite; - asset.lat = assetInfo.lat; - asset.lon = assetInfo.lon; asset.mimeType = mimeType; + asset.duration = assetInfo.duration; + try { const res = await this.assetRepository.save(asset); @@ -63,7 +63,7 @@ export class AssetService { lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(), }) .orderBy('a."createdAt"::date', 'DESC') - .take(10000) + // .take(5000) .getMany(); if (assets.length > 0) { diff --git a/server/src/api-v1/asset/dto/create-asset.dto.ts b/server/src/api-v1/asset/dto/create-asset.dto.ts index ce954217835f6..f3e469ed00e8e 100644 --- a/server/src/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/api-v1/asset/dto/create-asset.dto.ts @@ -24,8 +24,5 @@ export class CreateAssetDto { fileExtension: string; @IsOptional() - lat: string; - - @IsOptional() - lon: string; + duration: string; } diff --git a/server/src/api-v1/asset/entities/asset.entity.ts b/server/src/api-v1/asset/entities/asset.entity.ts index 97343004fabb9..4d78096d25d35 100644 --- a/server/src/api-v1/asset/entities/asset.entity.ts +++ b/server/src/api-v1/asset/entities/asset.entity.ts @@ -34,16 +34,10 @@ export class AssetEntity { isFavorite: boolean; @Column({ nullable: true }) - description: string; - - @Column({ nullable: true }) - lat: string; - - @Column({ nullable: true }) - lon: string; + mimeType: string; @Column({ nullable: true }) - mimeType: string; + duration: string; } export enum AssetType { diff --git a/server/src/modules/image-optimize/image-optimize.processor.ts b/server/src/modules/image-optimize/image-optimize.processor.ts index 026cb68840a56..f34c812c84048 100644 --- a/server/src/modules/image-optimize/image-optimize.processor.ts +++ b/server/src/modules/image-optimize/image-optimize.processor.ts @@ -60,13 +60,13 @@ export class ImageOptimizeProcessor { return 'ok'; } - @Process('resize-video') + @Process('get-video-thumbnail') async resizeUploadedVideo(job: Job) { - const { savedAsset }: { savedAsset: AssetEntity } = job.data; + const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data; const basePath = this.configService.get('UPLOAD_LOCATION'); - const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); - + // const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); + console.log(filename); // Create folder for thumb image if not exist const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; @@ -75,18 +75,16 @@ export class ImageOptimizeProcessor { } ffmpeg(savedAsset.originalPath) - .output(resizePath) - .noAudio() - .videoCodec('libx264') - .size('640x?') - .aspect('4:3') - .on('error', (e) => { - Logger.log(`Error resizing File: ${e}`, 'resizeUploadedVideo'); - }) - .on('end', async () => { - await this.assetRepository.update(savedAsset, { resizePath: resizePath }); + .thumbnail({ + count: 1, + timestamps: [1], + folder: resizeDir, + filename: `${filename}.png`, + size: '512x512', }) - .run(); + .on('end', async (a) => { + await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` }); + }); return 'ok'; } diff --git a/server/src/modules/image-optimize/image-optimize.service.ts b/server/src/modules/image-optimize/image-optimize.service.ts index 4824e9ffd6bb8..37f7a488bedd6 100644 --- a/server/src/modules/image-optimize/image-optimize.service.ts +++ b/server/src/modules/image-optimize/image-optimize.service.ts @@ -2,9 +2,7 @@ import { InjectQueue } from '@nestjs/bull'; import { Injectable } from '@nestjs/common'; import { Queue } from 'bull'; import { randomUUID } from 'crypto'; -import { join } from 'path'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; -import { AuthUserDto } from '../../decorators/auth-user.decorator'; @Injectable() export class AssetOptimizeService { @@ -24,11 +22,12 @@ export class AssetOptimizeService { }; } - public async resizeVideo(savedAsset: AssetEntity) { + public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) { const job = await this.optimizeQueue.add( - 'resize-video', + 'get-video-thumbnail', { savedAsset, + filename, }, { jobId: randomUUID() }, ); From ca0feb8c8d2d7e6dc725bfb0e7826babb04fba28 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 15:58:36 -0600 Subject: [PATCH 04/13] Fixed issue with video upload timeout and upper case file type on ios --- mobile/ios/Podfile.lock | 12 ++++++++++++ mobile/lib/modules/home/ui/immich_sliver_appbar.dart | 10 +++------- mobile/lib/shared/services/backup.service.dart | 7 ++++++- mobile/lib/utils/files_helper.dart | 4 +++- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 2f2de0c109a2d..ca7cce9a2f4a4 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -17,6 +17,10 @@ PODS: - Flutter - FMDB (>= 2.7.5) - Toast (4.0.0) + - video_player_avfoundation (0.0.1): + - Flutter + - wakelock (0.0.1): + - Flutter DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) @@ -25,6 +29,8 @@ DEPENDENCIES: - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) + - wakelock (from `.symlinks/plugins/wakelock/ios`) SPEC REPOS: trunk: @@ -44,6 +50,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/photo_manager/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/ios" + wakelock: + :path: ".symlinks/plugins/wakelock/ios" SPEC CHECKSUMS: device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed @@ -54,6 +64,8 @@ SPEC CHECKSUMS: photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff + wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index ad9bda06bdbdf..1fd6af557b6b8 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -101,13 +101,9 @@ class ImmichSliverAppBar extends ConsumerWidget { ), ], systemOverlayStyle: const SystemUiOverlayStyle( - // Status bar color - statusBarColor: Colors.indigo, - - // Status bar brightness (optional) - statusBarIconBrightness: Brightness.light, // For Android (dark icons) - statusBarBrightness: Brightness.dark, - ), + statusBarColor: Colors.white, + statusBarBrightness: Brightness.dark, + statusBarIconBrightness: Brightness.light), ), ); } diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index 3fb1975712439..08195ec9e25a3 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -35,7 +36,11 @@ class BackupService { for (var entity in assetList) { try { - file = await entity.file.timeout(const Duration(seconds: 5)); + if (entity.type == AssetType.video) { + file = await entity.file; + } else { + file = await entity.file.timeout(const Duration(seconds: 5)); + } if (file != null) { // reading exif diff --git a/mobile/lib/utils/files_helper.dart b/mobile/lib/utils/files_helper.dart index bede193907321..787a67a716ba8 100644 --- a/mobile/lib/utils/files_helper.dart +++ b/mobile/lib/utils/files_helper.dart @@ -1,10 +1,12 @@ +import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; class FileHelper { static getMimeType(String filePath) { + debugPrint(filePath); var fileExtension = p.extension(filePath).split(".")[1]; - switch (fileExtension) { + switch (fileExtension.toLowerCase()) { case 'gif': return {"type": "image", "subType": "gif"}; From 00830f8a05b6b649d8ca47225dd03904c6eff1e3 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 20:40:04 -0600 Subject: [PATCH 05/13] Added video player page --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/lib/modules/home/ui/image_grid.dart | 53 --------- .../lib/modules/home/ui/thumbnail_image.dart | 28 +++-- mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 34 +++++- .../lib/shared/views/video_viewer_page.dart | 101 ++++++++++++++++++ 6 files changed, 156 insertions(+), 63 deletions(-) create mode 100644 mobile/lib/shared/views/video_viewer_page.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 5f93e37958de4..875b7663530d2 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ createState() => _VideoThumbnailPlayerState(); -// } - -// class _VideoThumbnailPlayerState extends State { -// late VideoPlayerController videoPlayerController; -// ChewieController? chewieController; - -// @override -// void initState() { -// super.initState(); -// initializePlayer(); -// } - -// Future initializePlayer() async { -// videoPlayerController = -// VideoPlayerController.network('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'); - -// await Future.wait([ -// videoPlayerController.initialize(), -// ]); -// _createChewieController(); -// setState(() {}); -// } - -// _createChewieController() { -// chewieController = ChewieController( -// showControlsOnInitialize: false, -// videoPlayerController: videoPlayerController, -// autoPlay: true, -// looping: true, -// ); -// } - -// @override -// Widget build(BuildContext context) { -// return chewieController != null && chewieController!.videoPlayerController.value.isInitialized -// ? SizedBox( -// height: 300, -// width: 300, -// child: Chewie( -// controller: chewieController!, -// ), -// ) -// : const Text("Loading Video"); -// } -// } diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index 1225fd888db2c..227d1c2d423ae 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -21,14 +21,24 @@ class ThumbnailImage extends HookWidget { '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; return GestureDetector( onTap: () { - AutoRouter.of(context).push( - ImageViewerRoute( - imageUrl: - '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', - heroTag: asset.id, - thumbnailUrl: thumbnailRequestUrl, - ), - ); + if (asset.type == 'IMAGE') { + AutoRouter.of(context).push( + ImageViewerRoute( + imageUrl: + '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', + heroTag: asset.id, + thumbnailUrl: thumbnailRequestUrl, + ), + ); + } else { + debugPrint("Navigate to video player"); + + AutoRouter.of(context).push( + VideoViewerRoute( + videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', + ), + ); + } }, onLongPress: () {}, child: Hero( @@ -37,7 +47,7 @@ class ThumbnailImage extends HookWidget { cacheKey: "${asset.id}-${cacheKey.value}", width: 300, height: 300, - memCacheHeight: 250, + memCacheHeight: asset.type == 'IMAGE' ? 250 : 500, fit: BoxFit.cover, imageUrl: thumbnailRequestUrl, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 797117d9140a4..367352a27ab09 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/image_viewer_page.dart'; +import 'package:immich_mobile/shared/views/video_viewer_page.dart'; part 'router.gr.dart'; @@ -15,6 +16,7 @@ part 'router.gr.dart'; AutoRoute(page: HomePage, guards: [AuthGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), + AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index cc76a25b749f7..ce8ceb5d61e85 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -42,6 +42,12 @@ class _$AppRouter extends RootStackRouter { imageUrl: args.imageUrl, heroTag: args.heroTag, thumbnailUrl: args.thumbnailUrl)); + }, + VideoViewerRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl)); } }; @@ -52,7 +58,9 @@ class _$AppRouter extends RootStackRouter { RouteConfig(BackupControllerRoute.name, path: '/backup-controller-page', guards: [authGuard]), RouteConfig(ImageViewerRoute.name, - path: '/image-viewer-page', guards: [authGuard]) + path: '/image-viewer-page', guards: [authGuard]), + RouteConfig(VideoViewerRoute.name, + path: '/video-viewer-page', guards: [authGuard]) ]; } @@ -120,3 +128,27 @@ class ImageViewerRouteArgs { return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; } } + +/// generated route for +/// [VideoViewerPage] +class VideoViewerRoute extends PageRouteInfo { + VideoViewerRoute({Key? key, required String videoUrl}) + : super(VideoViewerRoute.name, + path: '/video-viewer-page', + args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl)); + + static const String name = 'VideoViewerRoute'; +} + +class VideoViewerRouteArgs { + const VideoViewerRouteArgs({this.key, required this.videoUrl}); + + final Key? key; + + final String videoUrl; + + @override + String toString() { + return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}'; + } +} diff --git a/mobile/lib/shared/views/video_viewer_page.dart b/mobile/lib/shared/views/video_viewer_page.dart new file mode 100644 index 0000000000000..6b006c06e8c07 --- /dev/null +++ b/mobile/lib/shared/views/video_viewer_page.dart @@ -0,0 +1,101 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:chewie/chewie.dart'; +import 'package:video_player/video_player.dart'; + +class VideoViewerPage extends StatelessWidget { + final String videoUrl; + + const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key); + + @override + Widget build(BuildContext context) { + String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + onPressed: () { + AutoRouter.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back_ios)), + ), + body: Center( + child: VideoThumbnailPlayer( + url: videoUrl, + jwtToken: jwtToken, + ), + ), + ); + } +} + +class VideoThumbnailPlayer extends StatefulWidget { + final String url; + final String? jwtToken; + + const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key); + + @override + State createState() => _VideoThumbnailPlayerState(); +} + +class _VideoThumbnailPlayerState extends State { + late VideoPlayerController videoPlayerController; + ChewieController? chewieController; + + @override + void initState() { + super.initState(); + initializePlayer(); + } + + Future initializePlayer() async { + videoPlayerController = + VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}); + + await Future.wait([ + videoPlayerController.initialize(), + ]); + _createChewieController(); + setState(() {}); + } + + _createChewieController() { + chewieController = ChewieController( + showOptions: true, + showControlsOnInitialize: false, + videoPlayerController: videoPlayerController, + autoPlay: true, + autoInitialize: false, + ); + } + + @override + void dispose() { + super.dispose(); + videoPlayerController.pause(); + videoPlayerController.dispose(); + chewieController!.dispose(); + } + + @override + Widget build(BuildContext context) { + return chewieController != null && chewieController!.videoPlayerController.value.isInitialized + ? SizedBox( + child: Chewie( + controller: chewieController!, + ), + ) + : const SizedBox( + width: 75, + height: 75, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + )); + } +} From f53b82de201d47d84d7c9aa5e5ffc38400978955 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 20:40:10 -0600 Subject: [PATCH 06/13] Added video player page --- server/src/modules/image-optimize/image-optimize.processor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/modules/image-optimize/image-optimize.processor.ts b/server/src/modules/image-optimize/image-optimize.processor.ts index f34c812c84048..c53b1123caa94 100644 --- a/server/src/modules/image-optimize/image-optimize.processor.ts +++ b/server/src/modules/image-optimize/image-optimize.processor.ts @@ -80,7 +80,6 @@ export class ImageOptimizeProcessor { timestamps: [1], folder: resizeDir, filename: `${filename}.png`, - size: '512x512', }) .on('end', async (a) => { await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` }); From c564bab4e86abd81905dd77795fad968bbcb8ef3 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 21:23:34 -0600 Subject: [PATCH 07/13] Fixing video player not play on ios --- mobile/ios/Runner/Info.plist | 17 ++++++++++++-- mobile/lib/main.dart | 9 ++++++++ .../modules/home/ui/immich_sliver_appbar.dart | 5 ----- .../lib/shared/views/video_viewer_page.dart | 22 +++++++++++-------- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index ded59d2292bf0..76dc408577ee1 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -41,9 +41,22 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance - + + NSPhotoLibraryUsageDescription - App need your agree, can visit your album + We need to manage backup your photos album + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 47222e5791208..c05e9384e611e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -12,6 +13,12 @@ void main() async { // Hive.registerAdapter(ImmichBackUpAssetAdapter()); // Hive.deleteBoxFromDisk(hiveImmichBox); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.light, + ), + ); + runApp(const ProviderScope(child: ImmichApp())); } @@ -69,6 +76,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv title: 'Immich', debugShowCheckedModeBanner: false, theme: ThemeData( + brightness: Brightness.light, primarySwatch: Colors.indigo, textTheme: GoogleFonts.workSansTextTheme( Theme.of(context).textTheme.apply(fontSizeFactor: 1.0), @@ -79,6 +87,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv foregroundColor: Colors.indigo, elevation: 1, centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle.dark, ), ), routeInformationParser: _immichRouter.defaultRouteParser(), diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index 1fd6af557b6b8..055403034b65d 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; @@ -100,10 +99,6 @@ class ImmichSliverAppBar extends ConsumerWidget { ], ), ], - systemOverlayStyle: const SystemUiOverlayStyle( - statusBarColor: Colors.white, - statusBarBrightness: Brightness.dark, - statusBarIconBrightness: Brightness.light), ), ); } diff --git a/mobile/lib/shared/views/video_viewer_page.dart b/mobile/lib/shared/views/video_viewer_page.dart index 6b006c06e8c07..9f42a52204346 100644 --- a/mobile/lib/shared/views/video_viewer_page.dart +++ b/mobile/lib/shared/views/video_viewer_page.dart @@ -55,14 +55,17 @@ class _VideoThumbnailPlayerState extends State { } Future initializePlayer() async { - videoPlayerController = - VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}); + try { + videoPlayerController = + VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}); - await Future.wait([ - videoPlayerController.initialize(), - ]); - _createChewieController(); - setState(() {}); + await videoPlayerController.initialize(); + _createChewieController(); + setState(() {}); + } catch (e) { + debugPrint("ERROR initialize video player"); + print(e); + } } _createChewieController() { @@ -80,7 +83,7 @@ class _VideoThumbnailPlayerState extends State { super.dispose(); videoPlayerController.pause(); videoPlayerController.dispose(); - chewieController!.dispose(); + chewieController?.dispose(); } @override @@ -96,6 +99,7 @@ class _VideoThumbnailPlayerState extends State { height: 75, child: CircularProgressIndicator.adaptive( strokeWidth: 2, - )); + ), + ); } } From 4d1c7702f44233ecfb602d01845c7a8b9b36599b Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 22:01:40 -0600 Subject: [PATCH 08/13] Added partial file streaming for ios/android video request --- server/src/api-v1/asset/asset.controller.ts | 80 +++++++++++++++++---- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 116f4fb3e306d..2bc720317eb30 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -9,10 +9,10 @@ import { Param, ValidationPipe, StreamableFile, - Response, Query, - Logger, - UploadedFile, + Response, + Headers, + BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { AssetService } from './asset.service'; @@ -25,6 +25,12 @@ import { ServeFileDto } from './dto/serve-file.dto'; import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetType } from './entities/asset.entity'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; +import { Response as Res } from 'express'; +import { promisify } from 'util'; +import { stat } from 'fs'; +import { pipeline } from 'stream'; + +const fileInfo = promisify(stat); @UseGuards(JwtAuthGuard) @Controller('asset') @@ -58,23 +64,71 @@ export class AssetController { @Get('/file') async serveFile( + @Headers() headers, @GetAuthUser() authUser: AuthUserDto, - @Response({ passthrough: true }) res, + @Response({ passthrough: true }) res: Res, @Query(ValidationPipe) query: ServeFileDto, ): Promise { let file = null; const asset = await this.assetService.findOne(authUser, query.did, query.aid); - res.set({ - 'Content-Type': asset.mimeType, - }); - if (query.isThumb === 'false' || !query.isThumb) { - file = createReadStream(asset.originalPath); - } else { - file = createReadStream(asset.resizePath); - } + // Handle Sending Images + if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { + res.set({ + 'Content-Type': asset.mimeType, + }); + + if (query.isThumb === 'false' || !query.isThumb) { + file = createReadStream(asset.originalPath); + } else { + file = createReadStream(asset.resizePath); + } + + return new StreamableFile(file); + } else if (asset.type == AssetType.VIDEO) { + // Handle Handling Video + + const { size } = await fileInfo(asset.originalPath); + const range = headers.range; - return new StreamableFile(file); + if (range) { + /** Extracting Start and End value from Range Header */ + let [start, end] = range.replace(/bytes=/, '').split('-'); + start = parseInt(start, 10); + end = end ? parseInt(end, 10) : size - 1; + + if (!isNaN(start) && isNaN(end)) { + start = start; + end = size - 1; + } + if (isNaN(start) && !isNaN(end)) { + start = size - end; + end = size - 1; + } + + // Handle unavailable range request + if (start >= size || end >= size) { + // Return the 416 Range Not Satisfiable. + res.status(416).set({ + 'Content-Range': `bytes */${size}`, + }); + + throw new BadRequestException('Bad Request Range'); + } + + /** Sending Partial Content With HTTP Code 206 */ + res.status(206).set({ + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': asset.mimeType, + }); + + const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); + + return new StreamableFile(videoStream); + } + } } @Get('/all') From bddc3d97a6bf2f292b97432551696358e799e5cb Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 22:52:31 -0600 Subject: [PATCH 09/13] Added nginx as proxy server for better file serving --- mobile/lib/modules/login/ui/login_form.dart | 2 +- server/.dockerignore | 3 ++- server/docker-compose.yml | 21 +++++++++++++++++++-- server/settings/nginx-conf/nginx.conf | 18 ++++++++++++++++++ server/src/api-v1/asset/asset.controller.ts | 12 +++++++++++- 5 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 server/settings/nginx-conf/nginx.conf diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index b99aaefe8e019..002e3c072cf1d 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -13,7 +13,7 @@ class LoginForm extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final usernameController = useTextEditingController(text: 'testuser@email.com'); final passwordController = useTextEditingController(text: 'password'); - final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:3000'); + final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); return Center( child: ConstrainedBox( diff --git a/server/.dockerignore b/server/.dockerignore index 2bf223c18ac19..834ab88b614c1 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,3 +1,4 @@ node_modules/ upload/ -dist/ \ No newline at end of file +dist/ + diff --git a/server/docker-compose.yml b/server/docker-compose.yml index fae9111f3cb4a..d896aff895609 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -10,8 +10,10 @@ services: target: development dockerfile: ./Dockerfile-minimal command: yarn start:dev - ports: - - "3000:3000" + # ports: + # - "3000:3000" + expose: + - 3000 volumes: - .:/usr/src/app - userdata:/usr/src/app/upload @@ -47,6 +49,21 @@ services: networks: - immich_network + nginx: + container_name: proxy_nginx + image: nginx:latest + volumes: + - ./settings/nginx-conf:/etc/nginx/conf.d + ports: + - 2283:80 + - 2284:443 + logging: + driver: none + networks: + - immich_network + depends_on: + - server + networks: immich_network: volumes: diff --git a/server/settings/nginx-conf/nginx.conf b/server/settings/nginx-conf/nginx.conf new file mode 100644 index 0000000000000..7642f8c8a4461 --- /dev/null +++ b/server/settings/nginx-conf/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + location / { + proxy_buffering off; + proxy_buffer_size 16k; + proxy_busy_buffers_size 24k; + proxy_buffers 64 4k; + proxy_force_ranges on; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_pass http://immich_server:3000; + } +} \ No newline at end of file diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 2bc720317eb30..acc1e5a052385 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -87,7 +87,6 @@ export class AssetController { return new StreamableFile(file); } else if (asset.type == AssetType.VIDEO) { // Handle Handling Video - const { size } = await fileInfo(asset.originalPath); const range = headers.range; @@ -108,6 +107,7 @@ export class AssetController { // Handle unavailable range request if (start >= size || end >= size) { + console.error('Bad Request'); // Return the 416 Range Not Satisfiable. res.status(416).set({ 'Content-Range': `bytes */${size}`, @@ -117,6 +117,8 @@ export class AssetController { } /** Sending Partial Content With HTTP Code 206 */ + console.log('Sendinf file with type ', asset.mimeType); + res.status(206).set({ 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', @@ -127,8 +129,16 @@ export class AssetController { const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); return new StreamableFile(videoStream); + } else { + res.set({ + 'Content-Type': asset.mimeType, + }); + + return new StreamableFile(createReadStream(asset.originalPath)); } } + + console.log('SHOULD NOT BE HERE'); } @Get('/all') From 782621c7b9be89d2ea701aa0c2ae895b77b7616d Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 23:28:07 -0600 Subject: [PATCH 10/13] update nginx and docker-compose file --- server/docker-compose.yml | 8 ++++---- server/settings/nginx-conf/nginx.conf | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d896aff895609..93600000c8ccb 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -10,10 +10,10 @@ services: target: development dockerfile: ./Dockerfile-minimal command: yarn start:dev - # ports: - # - "3000:3000" - expose: - - 3000 + ports: + - "3000:3000" + # expose: + # - 3000 volumes: - .:/usr/src/app - userdata:/usr/src/app/upload diff --git a/server/settings/nginx-conf/nginx.conf b/server/settings/nginx-conf/nginx.conf index 7642f8c8a4461..225164dd98bb3 100644 --- a/server/settings/nginx-conf/nginx.conf +++ b/server/settings/nginx-conf/nginx.conf @@ -1,4 +1,6 @@ server { + client_max_body_size 50000M; + listen 80; location / { From 211d627a96a7ab62ef479452c86ae176edadecd5 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 23:55:05 -0600 Subject: [PATCH 11/13] Video player working correctly --- mobile/lib/modules/home/ui/image_grid.dart | 2 +- mobile/lib/modules/home/ui/thumbnail_image.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index 32233a794403f..41114924615da 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/mobile/lib/modules/home/ui/image_grid.dart @@ -30,7 +30,7 @@ class ImageGrid extends ConsumerWidget { child: Row( children: [ Text( - assetGroup[index].duration.toString().substring(0, 7), + assetGroup[index].duration.toString(), style: const TextStyle( color: Colors.white, fontSize: 10, diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index 227d1c2d423ae..5d4f07b5914fe 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -47,7 +47,7 @@ class ThumbnailImage extends HookWidget { cacheKey: "${asset.id}-${cacheKey.value}", width: 300, height: 300, - memCacheHeight: asset.type == 'IMAGE' ? 250 : 500, + memCacheHeight: asset.type == 'IMAGE' ? 200 : 500, fit: BoxFit.cover, imageUrl: thumbnailRequestUrl, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, From 4a57a4c603d9ed1411b89fca31dc8cdcdfeeced8 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 23:55:24 -0600 Subject: [PATCH 12/13] Video player working correctly --- server/src/api-v1/asset/asset.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 47f41bee993b9..aea2970ba2fce 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -63,7 +63,7 @@ export class AssetService { lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(), }) .orderBy('a."createdAt"::date', 'DESC') - // .take(5000) + // .take(500) .getMany(); if (assets.length > 0) { From ecfb9d7c1627d70791dfcf5cee55f7d21a45dc9e Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 5 Feb 2022 23:59:25 -0600 Subject: [PATCH 13/13] Split duration to the second --- mobile/lib/modules/home/ui/image_grid.dart | 2 +- mobile/lib/modules/home/ui/thumbnail_image.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index 41114924615da..32233a794403f 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/mobile/lib/modules/home/ui/image_grid.dart @@ -30,7 +30,7 @@ class ImageGrid extends ConsumerWidget { child: Row( children: [ Text( - assetGroup[index].duration.toString(), + assetGroup[index].duration.toString().substring(0, 7), style: const TextStyle( color: Colors.white, fontSize: 10, diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index 5d4f07b5914fe..64857cc81e5e5 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -47,7 +47,7 @@ class ThumbnailImage extends HookWidget { cacheKey: "${asset.id}-${cacheKey.value}", width: 300, height: 300, - memCacheHeight: asset.type == 'IMAGE' ? 200 : 500, + memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, fit: BoxFit.cover, imageUrl: thumbnailRequestUrl, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},