1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-02 12:48:35 +02:00

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
This commit is contained in:
Alex 2022-06-04 18:34:11 -05:00 committed by GitHub
parent 53c3c916a6
commit ab6909bfbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 371 additions and 50 deletions

View File

@ -38,4 +38,4 @@ import { CommunicationModule } from '../communication/communication.module';
providers: [AssetService, AssetOptimizeService, BackgroundTaskService], providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
exports: [], exports: [],
}) })
export class AssetModule {} export class AssetModule { }

View File

@ -11,6 +11,7 @@ import { Response as Res } from 'express';
import { promisify } from 'util'; import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import ffmpeg from 'fluent-ffmpeg';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -185,7 +186,15 @@ export class AssetService {
} else if (asset.type == AssetType.VIDEO) { } else if (asset.type == AssetType.VIDEO) {
// Handle 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; const range = headers.range;
if (range) { if (range) {
@ -220,20 +229,22 @@ export class AssetService {
'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1, '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); return new StreamableFile(videoStream);
} else { } else {
res.set({ res.set({
'Content-Type': asset.mimeType, 'Content-Type': mimeType,
}); });
return new StreamableFile(createReadStream(asset.originalPath)); return new StreamableFile(createReadStream(videoPath));
} }
} }
} }

View File

@ -29,6 +29,9 @@ export class AssetEntity {
@Column({ nullable: true }) @Column({ nullable: true })
webpPath: string; webpPath: string;
@Column({ nullable: true })
encodedVideoPath: string;
@Column() @Column()
createdAt: string; createdAt: string;

View File

@ -65,7 +65,7 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod
export class AppModule implements NestModule { export class AppModule implements NestModule {
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('*');
} }
} }
} }

View File

@ -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";
`);
}
}

View File

@ -17,9 +17,10 @@ import { BackgroundTaskService } from './background-task.service';
removeOnFail: false, removeOnFail: false,
}, },
}), }),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
], ],
providers: [BackgroundTaskService, BackgroundTaskProcessor], providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService], exports: [BackgroundTaskService],
}) })
export class BackgroundTaskModule {} export class BackgroundTaskModule { }

View File

@ -33,4 +33,4 @@ import { AssetOptimizeService } from './image-optimize.service';
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService], providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
exports: [AssetOptimizeService], exports: [AssetOptimizeService],
}) })
export class ImageOptimizeModule {} export class ImageOptimizeModule { }

View File

@ -1,12 +1,30 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetModule } from '../../api-v1/asset/asset.module';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ImageConversionService } from './image-conversion.service'; import { ImageConversionService } from './image-conversion.service';
import { VideoConversionProcessor } from './video-conversion.processor';
import { VideoConversionService } from './video-conversion.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AssetEntity]), 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 { } export class ScheduleTasksModule { }

View File

@ -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 {}
}
}

View File

@ -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 },)
}
}
}

19
web/package-lock.json generated
View File

@ -23,6 +23,7 @@
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/leaflet": "^1.7.10", "@types/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
@ -260,6 +261,15 @@
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
"dev": true "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": { "node_modules/@types/geojson": {
"version": "7946.0.8", "version": "7946.0.8",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", "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==", "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
"dev": true "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": { "@types/geojson": {
"version": "7946.0.8", "version": "7946.0.8",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz",

View File

@ -19,6 +19,7 @@
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/leaflet": "^1.7.10", "@types/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",

View File

@ -12,10 +12,12 @@
import { serverEndpoint } from '../../constants'; import { serverEndpoint } from '../../constants';
import axios from 'axios'; import axios from 'axios';
import { downloadAssets } from '$lib/stores/download'; import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let selectedAsset: ImmichAsset; export let selectedAsset: ImmichAsset;
export let selectedIndex: number; export let selectedIndex: number;
let viewDeviceId: string; let viewDeviceId: string;
@ -157,7 +159,9 @@
</div> </div>
<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={() => { on:mouseenter={() => {
halfLeftHover = true; halfLeftHover = true;
halfRightHover = false; halfRightHover = false;
@ -168,7 +172,7 @@
on:click={navigateAssetBackward} on:click={navigateAssetBackward}
> >
<button <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} class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward} on:click={navigateAssetBackward}
> >
@ -182,19 +186,16 @@
{#if selectedAsset.type == AssetType.IMAGE} {#if selectedAsset.type == AssetType.IMAGE}
<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} /> <PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} />
{:else} {:else}
<div <VideoViewer assetId={viewAssetId} on:close={closeViewer} />
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>
{/if} {/if}
{/if} {/if}
{/key} {/key}
</div> </div>
<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:click={navigateAssetForward}
on:mouseenter={() => { on:mouseenter={() => {
halfLeftHover = false; halfLeftHover = false;
@ -205,7 +206,7 @@
}} }}
> >
<button <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} class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward} on:click={navigateAssetForward}
> >

View File

@ -2,23 +2,30 @@
import { AssetType, type ImmichAsset } from '../../models/immich-asset'; import { AssetType, type ImmichAsset } from '../../models/immich-asset';
import { session } from '$app/stores'; import { session } from '$app/stores';
import { createEventDispatcher, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy } from 'svelte';
import { fade } from 'svelte/transition'; import { fade, fly, slide } from 'svelte/transition';
import { serverEndpoint } from '../../constants'; import { serverEndpoint } from '../../constants';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte'; import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.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(); const dispatch = createEventDispatcher();
export let asset: ImmichAsset; export let asset: ImmichAsset;
export let groupIndex: number; export let groupIndex: number;
let imageContent: string; let imageData: string;
let videoData: string;
let mouseOver: boolean = false; let mouseOver: boolean = false;
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); $: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
let mouseOverIcon: boolean = false; let mouseOverIcon: boolean = false;
let videoPlayerNode: HTMLVideoElement; let videoPlayerNode: HTMLVideoElement;
let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
let videoProgress = '00:00';
const loadImageData = async () => { const loadImageData = async () => {
if ($session.user) { 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 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) { if ($session.user) {
const res = await fetch(serverEndpoint + videoUrl, { try {
method: 'GET', const res = await fetch(serverEndpoint + videoUrl, {
headers: { method: 'GET',
Authorization: 'bearer ' + $session.user.accessToken, 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.load();
videoPlayerNode.oncanplay = () => {
console.log('Can play video');
};
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 parseVideoDuration = (duration: string) => {
const timePart = duration.split(':'); const timePart = duration.split(':');
const hours = timePart[0]; const hours = timePart[0];
@ -70,7 +97,9 @@
} }
}; };
onDestroy(() => URL.revokeObjectURL(imageContent)); onDestroy(() => {
URL.revokeObjectURL(imageData);
});
const getSize = () => { const getSize = () => {
if (asset.exifInfo?.orientation === 'Rotate 90 CW') { if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
@ -81,19 +110,34 @@
return 'w-[235px] h-[235px]'; 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> </script>
<IntersectionObserver once={true} let:intersecting> <IntersectionObserver once={true} let:intersecting>
<div <div
class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`} class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
on:mouseenter={() => (mouseOver = true)} on:mouseenter={handleMouseOverThumbnail}
on:mouseleave={() => (mouseOver = false)} on:mouseleave={handleMouseLeaveThumbnail}
on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })} on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })}
> >
{#if mouseOver} {#if mouseOver}
<div <div
in:fade={{ duration: 200 }} 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 <div
on:mouseenter={() => (mouseOverIcon = true)} on:mouseenter={() => (mouseOverIcon = true)}
@ -105,18 +149,44 @@
</div> </div>
{/if} {/if}
<!-- Playback and info -->
{#if asset.type === AssetType.VIDEO} {#if asset.type === AssetType.VIDEO}
<div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center"> <div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10">
{parseVideoDuration(asset.duration)} {#if isThumbnailVideoPlaying}
<PlayCircleOutline size="24" /> <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> </div>
{/if} {/if}
<!-- Thumbnail -->
{#if intersecting} {#if intersecting}
{#await loadImageData()} {#await loadImageData()}
<div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div> <div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div>
{:then imageData} {:then imageData}
<img <img
in:fade={{ duration: 250 }}
src={imageData} src={imageData}
alt={asset.id} alt={asset.id}
class={`object-cover ${getSize()} transition-all duration-100 z-0`} class={`object-cover ${getSize()} transition-all duration-100 z-0`}
@ -125,12 +195,12 @@
{/await} {/await}
{/if} {/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}> <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" /> <track kind="captions" />
</video> </video>
</div> </div>
{/if} --> {/if}
</div> </div>
</IntersectionObserver> </IntersectionObserver>

View File

@ -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>

View File

@ -1,7 +1,7 @@
<div> <div>
<svg <svg
role="status" 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" viewBox="0 0 100 101"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -4,7 +4,6 @@
import type { Load } from '@sveltejs/kit'; import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ session }) => { export const load: Load = async ({ session }) => {
console.log('navigating to unknown paage');
if (!session.user) { if (!session.user) {
return { return {
status: 302, status: 302,