From ab6909bfbdd3a6015f9b2311ba732ad1f1dec938 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 4 Jun 2022 18:34:11 -0500 Subject: [PATCH] 20 video conversion for web view (#200) * Added job for video conversion every 1 minute * Handle get video as mp4 on the web * Auto play video on web on hovered * Added video player * Added animation and video duration to thumbnail player * Fixed issue with video not playing on hover * Added animation when loading thumbnail --- server/src/api-v1/asset/asset.module.ts | 2 +- server/src/api-v1/asset/asset.service.ts | 21 ++- .../src/api-v1/asset/entities/asset.entity.ts | 3 + server/src/app.module.ts | 2 +- ...583-UpdateAssetTableWithEncodeVideoPath.ts | 17 +++ .../background-task/background-task.module.ts | 3 +- .../image-optimize/image-optimize.module.ts | 2 +- .../schedule-tasks/schedule-tasks.module.ts | 20 ++- .../video-conversion.processor.ts | 56 ++++++++ .../video-conversion.service.ts | 50 +++++++ web/package-lock.json | 19 +++ web/package.json | 1 + .../asset-viewer/asset-viewer.svelte | 21 +-- .../asset-viewer/immich-thumbnail.svelte | 126 ++++++++++++++---- .../asset-viewer/video-viewer.svelte | 75 +++++++++++ .../components/shared/loading-spinner.svelte | 2 +- web/src/routes/photos/[assetId].svelte | 1 - 17 files changed, 371 insertions(+), 50 deletions(-) create mode 100644 server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts create mode 100644 server/src/modules/schedule-tasks/video-conversion.processor.ts create mode 100644 server/src/modules/schedule-tasks/video-conversion.service.ts create mode 100644 web/src/lib/components/asset-viewer/video-viewer.svelte diff --git a/server/src/api-v1/asset/asset.module.ts b/server/src/api-v1/asset/asset.module.ts index dda0958fb4..20fb5d1613 100644 --- a/server/src/api-v1/asset/asset.module.ts +++ b/server/src/api-v1/asset/asset.module.ts @@ -38,4 +38,4 @@ import { CommunicationModule } from '../communication/communication.module'; providers: [AssetService, AssetOptimizeService, BackgroundTaskService], exports: [], }) -export class AssetModule {} +export class AssetModule { } diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 59a0d11ed3..4c598d6acf 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -11,6 +11,7 @@ import { Response as Res } from 'express'; import { promisify } from 'util'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; +import ffmpeg from 'fluent-ffmpeg'; const fileInfo = promisify(stat); @@ -185,7 +186,15 @@ export class AssetService { } else if (asset.type == AssetType.VIDEO) { // Handle Video - const { size } = await fileInfo(asset.originalPath); + let videoPath = asset.originalPath; + let mimeType = asset.mimeType; + + if (query.isWeb && asset.mimeType == 'video/quicktime') { + videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath; + mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4'; + } + + const { size } = await fileInfo(videoPath); const range = headers.range; if (range) { @@ -220,20 +229,22 @@ export class AssetService { 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, - 'Content-Type': asset.mimeType, + 'Content-Type': mimeType, }); - const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); + + const videoStream = createReadStream(videoPath, { start: start, end: end }); return new StreamableFile(videoStream); } else { + res.set({ - 'Content-Type': asset.mimeType, + 'Content-Type': mimeType, }); - return new StreamableFile(createReadStream(asset.originalPath)); + return new StreamableFile(createReadStream(videoPath)); } } } diff --git a/server/src/api-v1/asset/entities/asset.entity.ts b/server/src/api-v1/asset/entities/asset.entity.ts index bce9fd5283..557187e58d 100644 --- a/server/src/api-v1/asset/entities/asset.entity.ts +++ b/server/src/api-v1/asset/entities/asset.entity.ts @@ -29,6 +29,9 @@ export class AssetEntity { @Column({ nullable: true }) webpPath: string; + @Column({ nullable: true }) + encodedVideoPath: string; + @Column() createdAt: string; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index b9a0fff80c..7aedb33c80 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -65,7 +65,7 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { if (process.env.NODE_ENV == 'development') { - consumer.apply(AppLoggerMiddleware).forRoutes('*'); + // consumer.apply(AppLoggerMiddleware).forRoutes('*'); } } } diff --git a/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts b/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts new file mode 100644 index 0000000000..0c05b0b9bb --- /dev/null +++ b/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + alter table assets + add column if not exists "encodedVideoPath" varchar default ''; + `) + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + alter table assets + drop column if exists "encodedVideoPath"; + `); + } +} diff --git a/server/src/modules/background-task/background-task.module.ts b/server/src/modules/background-task/background-task.module.ts index b610ebf9e3..ba912f5b2f 100644 --- a/server/src/modules/background-task/background-task.module.ts +++ b/server/src/modules/background-task/background-task.module.ts @@ -17,9 +17,10 @@ import { BackgroundTaskService } from './background-task.service'; removeOnFail: false, }, }), + TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]), ], providers: [BackgroundTaskService, BackgroundTaskProcessor], exports: [BackgroundTaskService], }) -export class BackgroundTaskModule {} +export class BackgroundTaskModule { } diff --git a/server/src/modules/image-optimize/image-optimize.module.ts b/server/src/modules/image-optimize/image-optimize.module.ts index 89e9cc739d..3d58ad8d0c 100644 --- a/server/src/modules/image-optimize/image-optimize.module.ts +++ b/server/src/modules/image-optimize/image-optimize.module.ts @@ -33,4 +33,4 @@ import { AssetOptimizeService } from './image-optimize.service'; providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService], exports: [AssetOptimizeService], }) -export class ImageOptimizeModule {} +export class ImageOptimizeModule { } diff --git a/server/src/modules/schedule-tasks/schedule-tasks.module.ts b/server/src/modules/schedule-tasks/schedule-tasks.module.ts index 6248762c0a..a37054df94 100644 --- a/server/src/modules/schedule-tasks/schedule-tasks.module.ts +++ b/server/src/modules/schedule-tasks/schedule-tasks.module.ts @@ -1,12 +1,30 @@ +import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetModule } from '../../api-v1/asset/asset.module'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { ImageConversionService } from './image-conversion.service'; +import { VideoConversionProcessor } from './video-conversion.processor'; +import { VideoConversionService } from './video-conversion.service'; @Module({ imports: [ TypeOrmModule.forFeature([AssetEntity]), + + BullModule.registerQueue({ + settings: {}, + name: 'video-conversion', + limiter: { + max: 1, + duration: 60000 + }, + defaultJobOptions: { + attempts: 3, + removeOnComplete: true, + removeOnFail: false, + }, + }), ], - providers: [ImageConversionService], + providers: [ImageConversionService, VideoConversionService, VideoConversionProcessor,], }) export class ScheduleTasksModule { } diff --git a/server/src/modules/schedule-tasks/video-conversion.processor.ts b/server/src/modules/schedule-tasks/video-conversion.processor.ts new file mode 100644 index 0000000000..da64d08acc --- /dev/null +++ b/server/src/modules/schedule-tasks/video-conversion.processor.ts @@ -0,0 +1,56 @@ +import { Process, Processor } from '@nestjs/bull'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Job } from 'bull'; +import { Repository } from 'typeorm'; +import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; +import { existsSync, mkdirSync } from 'fs'; +import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant'; +import ffmpeg from 'fluent-ffmpeg'; +import { Logger } from '@nestjs/common'; + +@Processor('video-conversion') +export class VideoConversionProcessor { + + constructor( + @InjectRepository(AssetEntity) + private assetRepository: Repository<AssetEntity>, + ) { } + + @Process('to-mp4') + async convertToMp4(job: Job) { + const { asset }: { asset: AssetEntity } = job.data; + + const basePath = APP_UPLOAD_LOCATION; + const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; + + if (!existsSync(encodedVideoPath)) { + mkdirSync(encodedVideoPath, { recursive: true }); + } + + const latestAssetInfo = await this.assetRepository.findOne({ id: asset.id }); + const savedEncodedPath = encodedVideoPath + "/" + latestAssetInfo.id + '.mp4' + + if (latestAssetInfo.encodedVideoPath == '') { + ffmpeg(latestAssetInfo.originalPath) + .outputOptions([ + '-crf 23', + '-preset ultrafast', + '-vcodec libx264', + '-acodec mp3', + '-vf scale=1280:-2' + ]) + .output(savedEncodedPath) + .on('start', () => Logger.log("Start Converting", 'VideoConversionMOV2MP4')) + .on('error', (a, b, c) => { + Logger.error('Cannot Convert Video', 'VideoConversionMOV2MP4') + console.log(a, b, c) + }) + .on('end', async () => { + Logger.log(`Converting Success ${latestAssetInfo.id}`, 'VideoConversionMOV2MP4') + await this.assetRepository.update({ id: latestAssetInfo.id }, { encodedVideoPath: savedEncodedPath }); + }).run(); + } + + return {} + } +} diff --git a/server/src/modules/schedule-tasks/video-conversion.service.ts b/server/src/modules/schedule-tasks/video-conversion.service.ts new file mode 100644 index 0000000000..e714476098 --- /dev/null +++ b/server/src/modules/schedule-tasks/video-conversion.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; +import sharp from 'sharp'; +import ffmpeg from 'fluent-ffmpeg'; +import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant'; +import { existsSync, mkdirSync } from 'fs'; +import { InjectQueue } from '@nestjs/bull/dist/decorators'; +import { Queue } from 'bull'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class VideoConversionService { + + + constructor( + @InjectRepository(AssetEntity) + private assetRepository: Repository<AssetEntity>, + + @InjectQueue('video-conversion') + private videoEncodingQueue: Queue + ) { } + + + // time ffmpeg -i 15065f4a-47ff-4aed-8c3e-c9fcf1840531.mov -crf 35 -preset ultrafast -vcodec libx264 -acodec mp3 -vf "scale=1280:-1" 15065f4a-47ff-4aed-8c3e-c9fcf1840531.mp4 + @Cron(CronExpression.EVERY_MINUTE + , { + name: 'video-encoding' + }) + async mp4Conversion() { + const assets = await this.assetRepository.find({ + where: { + type: 'VIDEO', + mimeType: 'video/quicktime', + encodedVideoPath: '' + }, + order: { + createdAt: 'DESC' + }, + take: 1 + }); + + if (assets.length > 0) { + const asset = assets[0]; + await this.videoEncodingQueue.add('to-mp4', { asset }, { jobId: asset.id },) + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 3d2be5a20d..dfee43d84b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,7 @@ "@types/axios": "^0.14.0", "@types/bcrypt": "^5.0.0", "@types/cookie": "^0.4.1", + "@types/fluent-ffmpeg": "^2.1.20", "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", @@ -260,6 +261,15 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.8", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", @@ -3418,6 +3428,15 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/geojson": { "version": "7946.0.8", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", diff --git a/web/package.json b/web/package.json index 6189345244..15030595c8 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@types/axios": "^0.14.0", "@types/bcrypt": "^5.0.0", "@types/cookie": "^0.4.1", + "@types/fluent-ffmpeg": "^2.1.20", "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c19df36132..f6382413eb 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,10 +12,12 @@ import { serverEndpoint } from '../../constants'; import axios from 'axios'; import { downloadAssets } from '$lib/stores/download'; + import VideoViewer from './video-viewer.svelte'; const dispatch = createEventDispatcher(); export let selectedAsset: ImmichAsset; + export let selectedIndex: number; let viewDeviceId: string; @@ -157,7 +159,9 @@ </div> <div - class="row-start-2 row-span-end col-start-1- col-span-full z-[1000] 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 ${ + selectedAsset.type == 'VIDEO' ? '' : 'z-[999]' + }`} on:mouseenter={() => { halfLeftHover = true; halfRightHover = false; @@ -168,7 +172,7 @@ on:click={navigateAssetBackward} > <button - class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4" + class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4" class:navigation-button-hover={halfLeftHover} on:click={navigateAssetBackward} > @@ -182,19 +186,16 @@ {#if selectedAsset.type == AssetType.IMAGE} <PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} /> {:else} - <div - class="w-full h-full bg-immich-primary/10 flex flex-col place-items-center place-content-center " - on:click={closeViewer} - > - <h1 class="animate-pulse font-bold text-4xl">Video viewer is under construction</h1> - </div> + <VideoViewer assetId={viewAssetId} on:close={closeViewer} /> {/if} {/if} {/key} </div> <div - class="row-start-2 row-span-full col-start-3 col-span-2 z-[1000] 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 ${ + selectedAsset.type == 'VIDEO' ? '' : 'z-[500]' + }`} on:click={navigateAssetForward} on:mouseenter={() => { halfLeftHover = false; @@ -205,7 +206,7 @@ }} > <button - class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4" + class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]" class:navigation-button-hover={halfRightHover} on:click={navigateAssetForward} > diff --git a/web/src/lib/components/asset-viewer/immich-thumbnail.svelte b/web/src/lib/components/asset-viewer/immich-thumbnail.svelte index 6b7e357cc4..9f5db1a36a 100644 --- a/web/src/lib/components/asset-viewer/immich-thumbnail.svelte +++ b/web/src/lib/components/asset-viewer/immich-thumbnail.svelte @@ -2,23 +2,30 @@ import { AssetType, type ImmichAsset } from '../../models/immich-asset'; import { session } from '$app/stores'; import { createEventDispatcher, onDestroy } from 'svelte'; - import { fade } from 'svelte/transition'; + import { fade, fly, slide } from 'svelte/transition'; import { serverEndpoint } from '../../constants'; import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte'; + import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte'; + import LoadingSpinner from '../shared/loading-spinner.svelte'; const dispatch = createEventDispatcher(); export let asset: ImmichAsset; export let groupIndex: number; - let imageContent: string; + let imageData: string; + let videoData: string; + let mouseOver: boolean = false; $: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); let mouseOverIcon: boolean = false; let videoPlayerNode: HTMLVideoElement; + let isThumbnailVideoPlaying = false; + let calculateVideoDurationIntervalHandler: NodeJS.Timer; + let videoProgress = '00:00'; const loadImageData = async () => { if ($session.user) { @@ -29,34 +36,54 @@ }, }); - imageContent = URL.createObjectURL(await res.blob()); + imageData = URL.createObjectURL(await res.blob()); - return imageContent; + return imageData; } }; const loadVideoData = async () => { - const videoUrl = `/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}`; + isThumbnailVideoPlaying = false; + const videoUrl = `/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isWeb=true`; if ($session.user) { - const res = await fetch(serverEndpoint + videoUrl, { - method: 'GET', - headers: { - Authorization: 'bearer ' + $session.user.accessToken, - }, - }); + try { + const res = await fetch(serverEndpoint + videoUrl, { + method: 'GET', + headers: { + Authorization: 'bearer ' + $session.user.accessToken, + }, + }); - const videoData = URL.createObjectURL(await res.blob()); + videoData = URL.createObjectURL(await res.blob()); + videoPlayerNode.src = videoData; - videoPlayerNode.src = videoData; - videoPlayerNode.load(); - videoPlayerNode.oncanplay = () => { - console.log('Can play video'); - }; + videoPlayerNode.load(); - return videoData; + videoPlayerNode.oncanplay = () => { + videoPlayerNode.muted = true; + videoPlayerNode.play(); + + isThumbnailVideoPlaying = true; + calculateVideoDurationIntervalHandler = setInterval(() => { + videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime)); + }, 1000); + }; + + return videoData; + } catch (e) {} } }; + const getVideoDurationInString = (currentTime: number) => { + const minute = Math.floor(currentTime / 60); + const second = currentTime % 60; + + const minuteText = minute >= 10 ? `${minute}` : `0${minute}`; + const secondText = second >= 10 ? `${second}` : `0${second}`; + + return minuteText + ':' + secondText; + }; + const parseVideoDuration = (duration: string) => { const timePart = duration.split(':'); const hours = timePart[0]; @@ -70,7 +97,9 @@ } }; - onDestroy(() => URL.revokeObjectURL(imageContent)); + onDestroy(() => { + URL.revokeObjectURL(imageData); + }); const getSize = () => { if (asset.exifInfo?.orientation === 'Rotate 90 CW') { @@ -81,19 +110,34 @@ return 'w-[235px] h-[235px]'; } }; + + const handleMouseOverThumbnail = () => { + mouseOver = true; + }; + + const handleMouseLeaveThumbnail = () => { + mouseOver = false; + URL.revokeObjectURL(videoData); + + if (calculateVideoDurationIntervalHandler) { + clearInterval(calculateVideoDurationIntervalHandler); + } + isThumbnailVideoPlaying = false; + videoProgress = '00:00'; + }; </script> <IntersectionObserver once={true} let:intersecting> <div class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`} - on:mouseenter={() => (mouseOver = true)} - on:mouseleave={() => (mouseOver = false)} + on:mouseenter={handleMouseOverThumbnail} + on:mouseleave={handleMouseLeaveThumbnail} on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })} > {#if mouseOver} <div in:fade={{ duration: 200 }} - class="w-full h-full bg-gradient-to-b from-gray-800/50 via-white/0 to-white/0 absolute p-2" + class="w-full bg-gradient-to-b from-gray-800/50 via-white/0 to-white/0 absolute p-2 z-10" > <div on:mouseenter={() => (mouseOverIcon = true)} @@ -105,18 +149,44 @@ </div> {/if} + <!-- Playback and info --> {#if asset.type === AssetType.VIDEO} - <div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center"> - {parseVideoDuration(asset.duration)} - <PlayCircleOutline size="24" /> + <div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"> + {#if isThumbnailVideoPlaying} + <span in:fly={{ x: -25, duration: 500 }}> + {videoProgress} + </span> + {:else} + <span in:fade={{ duration: 500 }}> + {parseVideoDuration(asset.duration)} + </span> + {/if} + + {#if mouseOver} + {#if isThumbnailVideoPlaying} + <span in:fly={{ x: 25, duration: 500 }}> + <PauseCircleOutline size="24" /> + </span> + {:else} + <span in:fade={{ duration: 250 }}> + <LoadingSpinner /> + </span> + {/if} + {:else} + <span in:fade={{ duration: 500 }}> + <PlayCircleOutline size="24" /> + </span> + {/if} </div> {/if} + <!-- Thumbnail --> {#if intersecting} {#await loadImageData()} <div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div> {:then imageData} <img + in:fade={{ duration: 250 }} src={imageData} alt={asset.id} class={`object-cover ${getSize()} transition-all duration-100 z-0`} @@ -125,12 +195,12 @@ {/await} {/if} - <!-- {#if mouseOver && asset.type === AssetType.VIDEO} + {#if mouseOver && asset.type === AssetType.VIDEO} <div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}> - <video autoplay class="border-2 h-[200px]" width="250px" bind:this={videoPlayerNode}> + <video muted class="h-full object-cover" width="250px" bind:this={videoPlayerNode}> <track kind="captions" /> </video> </div> - {/if} --> + {/if} </div> </IntersectionObserver> diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-viewer.svelte new file mode 100644 index 0000000000..1c775ab8b1 --- /dev/null +++ b/web/src/lib/components/asset-viewer/video-viewer.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import { session } from '$app/stores'; + import { serverEndpoint } from '$lib/constants'; + import { fade } from 'svelte/transition'; + + import type { ImmichAsset, ImmichExif } from '$lib/models/immich-asset'; + import { createEventDispatcher, onMount } from 'svelte'; + import LoadingSpinner from '../shared/loading-spinner.svelte'; + + export let assetId: string; + + let asset: ImmichAsset; + + const dispatch = createEventDispatcher(); + + let videoPlayerNode: HTMLVideoElement; + let isVideoLoading = true; + + onMount(async () => { + if ($session.user) { + const res = await fetch(serverEndpoint + '/asset/assetById/' + assetId, { + headers: { + Authorization: 'bearer ' + $session.user.accessToken, + }, + }); + asset = await res.json(); + + await loadVideoData(); + } + }); + + const loadVideoData = async () => { + isVideoLoading = true; + const videoUrl = `/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isWeb=true`; + if ($session.user) { + try { + const res = await fetch(serverEndpoint + videoUrl, { + method: 'GET', + headers: { + Authorization: 'bearer ' + $session.user.accessToken, + }, + }); + + const videoData = URL.createObjectURL(await res.blob()); + videoPlayerNode.src = videoData; + + videoPlayerNode.load(); + + videoPlayerNode.oncanplay = () => { + videoPlayerNode.muted = true; + videoPlayerNode.play(); + videoPlayerNode.muted = false; + + isVideoLoading = false; + }; + + return videoData; + } catch (e) {} + } + }; +</script> + +<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none"> + {#if asset} + <video controls class="h-full object-contain" bind:this={videoPlayerNode}> + <track kind="captions" /> + </video> + + {#if isVideoLoading} + <div class="absolute w-full h-full bg-black/50 flex place-items-center place-content-center"> + <LoadingSpinner /> + </div> + {/if} + {/if} +</div> diff --git a/web/src/lib/components/shared/loading-spinner.svelte b/web/src/lib/components/shared/loading-spinner.svelte index d5be9eb872..6dcd23b6dc 100644 --- a/web/src/lib/components/shared/loading-spinner.svelte +++ b/web/src/lib/components/shared/loading-spinner.svelte @@ -1,7 +1,7 @@ <div> <svg role="status" - class="w-8 h-8 mr-2 text-gray-400 animate-spin dark:text-gray-600 fill-immich-primary" + class={`w-[24px] h-[24px] text-gray-400 animate-spin dark:text-gray-600 fill-immich-primary`} viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" diff --git a/web/src/routes/photos/[assetId].svelte b/web/src/routes/photos/[assetId].svelte index bee566632d..903e70d696 100644 --- a/web/src/routes/photos/[assetId].svelte +++ b/web/src/routes/photos/[assetId].svelte @@ -4,7 +4,6 @@ import type { Load } from '@sveltejs/kit'; export const load: Load = async ({ session }) => { - console.log('navigating to unknown paage'); if (!session.user) { return { status: 302,