You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-06-24 04:46:50 +02:00
feat: support iOS LivePhoto backup (#950)
This commit is contained in:
@ -440,6 +440,12 @@ export interface AssetResponseDto {
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'smartInfo'?: SmartInfoResponseDto;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'livePhotoVideoId': string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
1
web/src/app.d.ts
vendored
1
web/src/app.d.ts
vendored
@ -13,6 +13,7 @@ declare namespace App {
|
||||
// Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte
|
||||
// To fix the <svelte:window... in components/asset-viewer/photo-viewer.svelte
|
||||
declare namespace svelte.JSX {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface HTMLAttributes<T> {
|
||||
oncopyImage?: () => void;
|
||||
}
|
||||
|
@ -12,12 +12,16 @@
|
||||
import Star from 'svelte-material-icons/Star.svelte';
|
||||
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
|
||||
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { AssetResponseDto } from '../../../api';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let showCopyButton: boolean;
|
||||
export let showMotionPlayButton: boolean;
|
||||
export let isMotionPhotoPlaying = false;
|
||||
|
||||
const isOwner = asset.ownerId === $page.data.user.id;
|
||||
|
||||
@ -48,17 +52,41 @@
|
||||
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||
</div>
|
||||
<div class="text-white flex gap-2">
|
||||
{#if showMotionPlayButton}
|
||||
{#if isMotionPhotoPlaying}
|
||||
<CircleIconButton
|
||||
logo={MotionPauseOutline}
|
||||
title="Stop Motion Photo"
|
||||
on:click={() => dispatch('stopMotionPhoto')}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
logo={MotionPlayOutline}
|
||||
title="Play Motion Photo"
|
||||
on:click={() => dispatch('playMotionPhoto')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showCopyButton}
|
||||
<CircleIconButton
|
||||
logo={ContentCopy}
|
||||
title="Copy Image"
|
||||
on:click={() => {
|
||||
const copyEvent = new CustomEvent('copyImage');
|
||||
window.dispatchEvent(copyEvent);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
|
||||
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
|
||||
<CircleIconButton
|
||||
logo={CloudDownloadOutline}
|
||||
on:click={() => dispatch('download')}
|
||||
title="Download"
|
||||
/>
|
||||
<CircleIconButton
|
||||
logo={InformationOutline}
|
||||
on:click={() => dispatch('showDetail')}
|
||||
title="Info"
|
||||
/>
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
logo={asset.isFavorite ? Star : StarOutline}
|
||||
@ -66,8 +94,12 @@
|
||||
title="Favorite"
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
|
||||
<CircleIconButton logo={DotsVertical} on:click={(event) => showOptionsMenu(event)} />
|
||||
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
|
||||
<CircleIconButton
|
||||
logo={DotsVertical}
|
||||
on:click={(event) => showOptionsMenu(event)}
|
||||
title="More"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -39,7 +39,7 @@
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let isShowAlbumPicker = false;
|
||||
let addToSharedAlbum = true;
|
||||
|
||||
let shouldPlayMotionPhoto = false;
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||
|
||||
onMount(() => {
|
||||
@ -88,10 +88,20 @@
|
||||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
|
||||
const downloadFile = async () => {
|
||||
const handleDownload = () => {
|
||||
if (asset.livePhotoVideoId) {
|
||||
downloadFile(asset.livePhotoVideoId, true);
|
||||
downloadFile(asset.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(asset.id, false);
|
||||
};
|
||||
|
||||
const downloadFile = async (assetId: string, isLivePhoto: boolean) => {
|
||||
try {
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageExtension = isLivePhoto ? 'mov' : asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
|
||||
// If assets is already download -> return;
|
||||
@ -101,7 +111,7 @@
|
||||
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
|
||||
const { data, status } = await api.assetApi.downloadFile(asset.id, false, false, {
|
||||
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
@ -221,14 +231,18 @@
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
showCopyButton={asset.type === AssetTypeEnum.Image}
|
||||
showMotionPlayButton={!!asset.livePhotoVideoId}
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
showCopyButton={asset.type === AssetTypeEnum.Image}
|
||||
on:download={handleDownload}
|
||||
on:delete={deleteAsset}
|
||||
on:favorite={toggleFavorite}
|
||||
on:addToAlbum={() => openAlbumPicker(false)}
|
||||
on:addToSharedAlbum={() => openAlbumPicker(true)}
|
||||
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -257,7 +271,15 @@
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
assetId={asset.livePhotoVideoId}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
{:else}
|
||||
<PhotoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isVideoLoading = true;
|
||||
let videoUrl: string;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
|
||||
@ -49,6 +50,7 @@
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
on:canplay={handleCanPlay}
|
||||
on:ended={() => dispatch('onVideoEnded')}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
|
@ -5,6 +5,8 @@
|
||||
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 MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
||||
|
||||
@ -19,6 +21,7 @@
|
||||
let imageData: string;
|
||||
|
||||
let mouseOver = false;
|
||||
let playMotionVideo = false;
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon = false;
|
||||
@ -28,10 +31,15 @@
|
||||
let videoProgress = '00:00';
|
||||
let videoUrl: string;
|
||||
|
||||
const loadVideoData = async () => {
|
||||
const loadVideoData = async (isLivePhoto: boolean) => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
|
||||
videoUrl = getFileUrl(asset.id, false, true);
|
||||
if (isLivePhoto && asset.livePhotoVideoId) {
|
||||
console.log('get file url');
|
||||
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
|
||||
} else {
|
||||
videoUrl = getFileUrl(asset.id, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoDurationInString = (currentTime: number) => {
|
||||
@ -202,6 +210,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div
|
||||
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
|
||||
>
|
||||
<span
|
||||
in:fade={{ duration: 500 }}
|
||||
on:mouseenter={() => {
|
||||
playMotionVideo = true;
|
||||
loadVideoData(true);
|
||||
}}
|
||||
on:mouseleave={() => (playMotionVideo = false)}
|
||||
>
|
||||
{#if playMotionVideo}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
<MotionPauseOutline size="24" />
|
||||
</span>
|
||||
{:else}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
<MotionPlayOutline size="24" />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<!-- {/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail -->
|
||||
{#if intersecting}
|
||||
<img
|
||||
@ -217,7 +251,27 @@
|
||||
{/if}
|
||||
|
||||
{#if mouseOver && asset.type === AssetTypeEnum.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(false)}>
|
||||
{#if videoUrl}
|
||||
<video
|
||||
muted
|
||||
autoplay
|
||||
preload="none"
|
||||
class="h-full object-cover"
|
||||
width="250px"
|
||||
style:width={`${thumbnailSize}px`}
|
||||
on:canplay={handleCanPlay}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div class="absolute w-full h-full top-0">
|
||||
{#if videoUrl}
|
||||
<video
|
||||
muted
|
||||
|
Reference in New Issue
Block a user