pull/653/merge
Varun Patil 2023-11-02 13:00:17 -07:00
commit 7b3119c133
15 changed files with 803 additions and 193 deletions

View File

@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- **Feature**: RAW files are now hidden (stacked) when another file with the same basename exists ([#537](https://github.com/pulsejet/memories/issues/537), [#152](https://github.com/pulsejet/memories/issues/152), [#419](https://github.com/pulsejet/memories/issues/419))
- **Feature**: Bulk rotating of images. You can now rotate images losslessly by editing the rotation EXIF metadata. ([#856](https://github.com/pulsejet/memories/issues/856))
- **Feature**: Icon animation when playing live photos ([#898](https://github.com/pulsejet/memories/issues/898))
- **Feature**: Swipe to refresh on timeline ([#547](https://github.com/pulsejet/memories/issues/547))
- **Bugfix**: Allow switching video to direct on Safari ([#650](https://github.com/pulsejet/memories/issues/650))
- Many other [bug fixes](https://github.com/pulsejet/memories/milestone/18?closed=1)
- Android app is now open source ([see](https://github.com/pulsejet/memories/tree/master/android))

16
l10n/zh_CN.js vendored
View File

@ -61,12 +61,20 @@ OC.L10N.register(
"Timeline Path" : "时间线路径",
"Square grid mode" : "方形网格模式",
"Show past photos on top of timeline" : "在时间线顶部显示过去的照片",
"Stack RAW files with same name" : "堆叠同名 RAW 文件",
"Photo Viewer" : "照片浏览器",
"Autoplay Live Photos" : "自动播放实时照片",
"Show full file path in sidebar" : "在侧边栏显示完整的文件路径",
"High resolution image loading behavior" : "高清图像加载偏好",
"Load high resolution image on zoom" : "在缩放时加载高清图像",
"Always load high resolution image (not recommended)" : "总是加载高清图像(不推荐)",
"Never load high resolution image" : "不加载高清图像",
"Account" : "账户",
"Logged in as {user}" : "以 {user} 登录",
"Sign out" : "登出",
"Device Folders" : "设备文件夹",
"Local folders to include in the timeline view" : "要包含在时间线视图中的本地文件夹",
"Run initial device setup" : "运行设备初始化设定",
"Folders Path" : "文件夹路径",
"Show hidden folders" : "显示隐藏文件夹",
"Sort folders oldest-first" : "将文件夹从最旧开始排序",
@ -81,6 +89,9 @@ OC.L10N.register(
"Failed to update setting" : "更新设置失败",
"Albums support is enabled through the Photos app." : "相册支持通过“照片”应用启用。",
"Albums are disabled because the Photos app is not available." : "相册已禁用,因为“照片”应用不可用。",
"Recognize is installed and enabled for face recognition." : "Recognize 人脸识别已安装并启用。",
"Recognize is installed but not enabled for face recognition." : "Recognize 人脸识别已安装但并未启用。",
"Recognize is not installed. Face recognition and object tagging may be unavailable." : "Recognize 未安装。人脸识别和物品标记可能无法使用。",
"Face Recognition is installed and enabled" : "人脸识别已安装并启用",
"Preview generator is installed and enabled. Additional configuration may still be required." : "预览生成器已安装并启用。可能还需要额外的配置。",
"Preview generator is not installed and configured. This may make Memories very slow." : "预览生成器未安装和配置。这可能会使“记忆”非常缓慢。",
@ -93,6 +104,11 @@ OC.L10N.register(
"If you are using Imaginary for preview generation, you can ignore this section." : "如果您正在使用Imaginary生成预览则可以忽略此部分。",
"To enable RAW support, install the Camera RAW Previews app." : "要启用RAW支持请安装Camera RAW Previews应用。",
"Documentation." : "文档",
"PHP-Imagick is available [{version}]." : "PHP-Imagick 可用 [{version}]。",
"PHP-Imagick is not available." : "PHP-Imagick 不可用。",
"Image editing will not work correctly." : "图像编辑无法正常使用。",
"Thumbnail generation may not work for some formats (HEIC, TIFF)." : "缩略图生成可能不适用于某些格式HEICTIFF。",
"Thumbnails for videos will be generated with this binary." : "视频缩略图将使用此二进制文件生成。",
"The following MIME types are configured for preview generation." : "为生成预览配置了以下MIME类型。",
"Max preview size (trade-off between quality and storage requirements)." : "最大预览大小(质量和存储需求之间的权衡)",
"Max memory for preview generation (MB)" : "生成预览时的最大内存(MB)",

16
l10n/zh_CN.json vendored
View File

@ -59,12 +59,20 @@
"Timeline Path" : "时间线路径",
"Square grid mode" : "方形网格模式",
"Show past photos on top of timeline" : "在时间线顶部显示过去的照片",
"Stack RAW files with same name" : "堆叠同名 RAW 文件",
"Photo Viewer" : "照片浏览器",
"Autoplay Live Photos" : "自动播放实时照片",
"Show full file path in sidebar" : "在侧边栏显示完整的文件路径",
"High resolution image loading behavior" : "高清图像加载偏好",
"Load high resolution image on zoom" : "在缩放时加载高清图像",
"Always load high resolution image (not recommended)" : "总是加载高清图像(不推荐)",
"Never load high resolution image" : "不加载高清图像",
"Account" : "账户",
"Logged in as {user}" : "以 {user} 登录",
"Sign out" : "登出",
"Device Folders" : "设备文件夹",
"Local folders to include in the timeline view" : "要包含在时间线视图中的本地文件夹",
"Run initial device setup" : "运行设备初始化设定",
"Folders Path" : "文件夹路径",
"Show hidden folders" : "显示隐藏文件夹",
"Sort folders oldest-first" : "将文件夹从最旧开始排序",
@ -79,6 +87,9 @@
"Failed to update setting" : "更新设置失败",
"Albums support is enabled through the Photos app." : "相册支持通过“照片”应用启用。",
"Albums are disabled because the Photos app is not available." : "相册已禁用,因为“照片”应用不可用。",
"Recognize is installed and enabled for face recognition." : "Recognize 人脸识别已安装并启用。",
"Recognize is installed but not enabled for face recognition." : "Recognize 人脸识别已安装但并未启用。",
"Recognize is not installed. Face recognition and object tagging may be unavailable." : "Recognize 未安装。人脸识别和物品标记可能无法使用。",
"Face Recognition is installed and enabled" : "人脸识别已安装并启用",
"Preview generator is installed and enabled. Additional configuration may still be required." : "预览生成器已安装并启用。可能还需要额外的配置。",
"Preview generator is not installed and configured. This may make Memories very slow." : "预览生成器未安装和配置。这可能会使“记忆”非常缓慢。",
@ -91,6 +102,11 @@
"If you are using Imaginary for preview generation, you can ignore this section." : "如果您正在使用Imaginary生成预览则可以忽略此部分。",
"To enable RAW support, install the Camera RAW Previews app." : "要启用RAW支持请安装Camera RAW Previews应用。",
"Documentation." : "文档",
"PHP-Imagick is available [{version}]." : "PHP-Imagick 可用 [{version}]。",
"PHP-Imagick is not available." : "PHP-Imagick 不可用。",
"Image editing will not work correctly." : "图像编辑无法正常使用。",
"Thumbnail generation may not work for some formats (HEIC, TIFF)." : "缩略图生成可能不适用于某些格式HEICTIFF。",
"Thumbnails for videos will be generated with this binary." : "视频缩略图将使用此二进制文件生成。",
"The following MIME types are configured for preview generation." : "为生成预览配置了以下MIME类型。",
"Max preview size (trade-off between quality and storage requirements)." : "最大预览大小(质量和存储需求之间的权衡)",
"Max memory for preview generation (MB)" : "生成预览时的最大内存(MB)",

View File

@ -259,7 +259,12 @@ class ImageController extends GenericApiController
// Set the exif data
Exif::setFileExif($file, $raw);
return new JSONResponse([], Http::STATUS_OK);
// If rotation changed then update the previews
if ($raw['Orientation'] ?? false) {
$this->deletePreviews($file);
}
return $this->info($id, true);
});
}
@ -452,4 +457,22 @@ class ImageController extends GenericApiController
// Get the tag names
return array_map(static fn ($t) => $t->getName(), $visible);
}
/**
* Invalidate previews for a file.
*/
private function deletePreviews(\OCP\Files\File $file): void
{
try {
$previewRoot = new \OC\Preview\Storage\Root(
\OC::$server->get(IRootFolder::class),
\OC::$server->get(\OC\SystemConfig::class),
);
$fileId = (string) $file->getId();
$previewRoot->getFolder($fileId)->delete();
} catch (\Exception $e) {
return;
}
}
}

View File

@ -334,7 +334,7 @@ class Exif
$data['SourceFile'] = $path;
$raw = json_encode([$data], JSON_UNESCAPED_UNICODE);
$cmd = array_merge(self::getExiftool(), [
'-overwrite_original',
'-overwrite_original', '-n',
'-api', 'LargeFileSupport=1',
'-json=-', $path,
]);

View File

@ -370,11 +370,8 @@ class Util
public static function callerIsNative(): bool
{
// Should not use IRequest here since this method is called during registration
if (\array_key_exists('HTTP_X_REQUESTED_WITH', $_SERVER)) {
return 'gallery.memories' === $_SERVER['HTTP_X_REQUESTED_WITH'];
}
return str_contains($_SERVER['HTTP_USER_AGENT'] ?? '', 'MemoriesNative');
return 'gallery.memories' === ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')
|| str_contains($_SERVER['HTTP_USER_AGENT'] ?? '', 'MemoriesNative');
}
/**

View File

@ -95,6 +95,7 @@ export default defineComponent({
emits: {
interactend: () => true,
scroll: (event: { current: number; previous: number }) => true,
},
data: () => ({
@ -223,11 +224,13 @@ export default defineComponent({
const scroll = this.recycler?.$el?.scrollTop || 0;
// Emit scroll event
utils.bus.emit('memories.recycler.scroll', {
const event = {
current: scroll,
previous: this.lastKnownRecyclerScroll,
dynTopMatterVisible: scroll < this.dynTopMatterHeight,
});
};
utils.bus.emit('memories.recycler.scroll', event);
this.$emit('scroll', event);
this.lastKnownRecyclerScroll = scroll;
// Get cursor px position

View File

@ -62,6 +62,7 @@ import MoveIcon from 'vue-material-design-icons/ImageMove.vue';
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
import type { IDay, IHeadRow, IPhoto, IRow } from '@typings';
@ -231,6 +232,11 @@ export default defineComponent({
icon: EditFileIcon,
callback: this.editMetadataSelection.bind(this),
},
{
name: t('memories', 'Rotate / Flip'),
icon: RotateLeftIcon,
callback: () => this.editMetadataSelection(this.selection, [5]),
},
{
name: t('memories', 'View in folder'),
icon: OpenInNewIcon,

View File

@ -0,0 +1,228 @@
<template>
<div @touchstart.passive="touchstart" @touchmove.passive="touchmove" @touchend.passive="touchend">
<div v-show="show" class="swipe-progress" :style="{ background: gradient }" :class="{ animate, wasSwiped }"></div>
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
const SWIPE_PX = 250;
export default defineComponent({
name: 'SwipeRefresh',
props: {
refresh: {
type: Function as PropType<() => Promise<any>>,
required: true,
},
allowSwipe: {
type: Boolean,
default: true,
},
state: {
type: Number,
default: Math.random(),
},
},
data: () => ({
/** Is active interaction */
on: false,
/** Start touch Y coordinate */
start: 0,
/** End touch Y coordinate */
end: 0,
/** Percentage progress to show in swiping */
progress: 0,
/** Next update frame reference */
updateFrame: 0,
// Loading animation state
loading: false,
animate: false,
wasSwiped: true,
firstcycle: 0,
}),
emits: [],
mounted() {
this.animate = this.loading; // start if needed
},
beforeDestroy() {
this.reset();
},
watch: {
state() {
this.reset();
},
loading() {
this.wasSwiped = this.progress >= 100;
if (!this.wasSwiped) {
// The loading animation was triggered from elsewhere
// let it continue normally
this.animate = this.loading;
return;
}
// Let the animation run for at least half cycle
// if the user pulled down, so we provide good feedback
// that something actually happened
if (this.loading) {
if (!this.animate) {
this.firstcycle = window.setTimeout(() => {
this.firstcycle = 0;
this.animate = this.loading;
}, 750);
}
this.animate = this.loading;
} else {
if (!this.firstcycle) {
this.animate = this.loading;
}
}
},
},
computed: {
show() {
return (this.on && this.progress) || this.animate;
},
gradient() {
if (this.animate) {
// CSS animation below
return undefined;
}
// Pull down progress
const p = this.progress;
const outer = 'transparent';
const inner = 'var(--color-primary)';
return `radial-gradient(circle at center, ${inner} 0, ${inner} ${p}%, ${outer} ${p}%, ${outer} 100%)`;
},
},
methods: {
reset() {
// Clear events
window.cancelAnimationFrame(this.updateFrame);
window.clearTimeout(this.firstcycle);
// Reset state
this.on = false;
this.progress = 0;
this.updateFrame = 0;
this.loading = false;
this.animate = false;
this.wasSwiped = true;
this.firstcycle = 0;
},
/** Start gesture on container (passive) */
touchstart(event: TouchEvent) {
if (!this.allowSwipe) return;
const touch = event.touches[0];
this.end = this.start = touch.clientY;
this.progress = 0;
this.on = true;
},
/** Execute gesture on container (passive) */
touchmove(event: TouchEvent) {
if (!this.allowSwipe || !this.on) return;
const touch = event.touches[0];
this.end = touch.clientY;
// Update progress only once per frame
this.updateFrame ||= window.requestAnimationFrame(async () => {
this.updateFrame = 0;
// Compute percentage of swipe
const delta = (this.end - this.start) / SWIPE_PX;
this.progress = Math.min(Math.max(0, delta * 100), 100);
// Execute action on threshold
if (this.progress >= 100) {
this.on = false;
const state = this.state;
try {
this.loading = true;
await this.refresh();
} finally {
if (this.state === state) {
this.loading = false;
}
}
}
});
},
/** End gesture on container (passive) */
touchend(event: TouchEvent) {
this.on = false;
},
},
});
</script>
<style lang="scss" scoped>
.swipe-progress {
position: absolute;
z-index: 400; // above selection manager
top: 0;
width: 100%;
height: 3px;
pointer-events: none;
&.animate {
background-position: center;
$progress-inside: radial-gradient(
circle at center,
transparent 0%,
transparent 1%,
var(--color-primary) 1%,
var(--color-primary) 100%
);
$progress-outside: radial-gradient(
circle at center,
var(--color-primary) 0%,
var(--color-primary) 1%,
transparent 1%,
transparent 100%
);
animation: swipe-loading 1.5s ease infinite;
&.wasSwiped {
animation-delay: -0.75s;
}
@keyframes swipe-loading {
0% {
background-image: $progress-outside;
background-size: 100% 100%;
}
49.99% {
background-image: $progress-outside;
background-size: 11000% 11000%;
}
50% {
background-image: $progress-inside;
background-size: 100% 100%;
}
100% {
background-image: $progress-inside;
background-size: 11000% 11000%;
}
}
}
}
</style>

View File

@ -1,5 +1,11 @@
<template>
<div class="container no-user-select" ref="container">
<SwipeRefresh
class="container no-user-select"
ref="container"
:refresh="softRefreshSync"
:allowSwipe="allowSwipe"
:state="state"
>
<!-- Loading indicator -->
<XLoadingIcon class="loading-icon centered" v-if="loading" />
@ -74,7 +80,8 @@
:fullHeight="scrollerHeight"
:recycler="refs.recycler"
:recyclerBefore="refs.recyclerBefore"
@interactend="loadScrollView()"
@interactend="loadScrollView"
@scroll="currentScroll = $event.current"
/>
<SelectionManager
@ -85,7 +92,7 @@
:recycler="refs.recycler?.$el"
@updateLoading="updateLoading"
/>
</div>
</SwipeRefresh>
</template>
<script lang="ts">
@ -103,6 +110,7 @@ import Photo from '@components/frame/Photo.vue';
import ScrollerManager from '@components/ScrollerManager.vue';
import SelectionManager from '@components/SelectionManager.vue';
import Viewer from '@components/viewer/Viewer.vue';
import SwipeRefresh from './SwipeRefresh.vue';
import EmptyContent from '@components/top-matter/EmptyContent.vue';
import TopMatter from '@components/top-matter/TopMatter.vue';
@ -133,6 +141,7 @@ export default defineComponent({
SelectionManager,
ScrollerManager,
Viewer,
SwipeRefresh,
},
mixins: [UserConfig],
@ -166,6 +175,8 @@ export default defineComponent({
currentStart: 0,
/** Current end index */
currentEnd: 0,
/** Current physical scroll position */
currentScroll: 0,
/** Resizing timer */
resizeTimer: null as number | null,
/** Height of the scroller */
@ -219,7 +230,7 @@ export default defineComponent({
computed: {
refs() {
return this.$refs as {
container?: HTMLDivElement;
container?: InstanceType<typeof SwipeRefresh>;
topmatter?: InstanceType<typeof TopMatter>;
dtm?: InstanceType<typeof DynamicTopMatter>;
recycler?: VueRecyclerType;
@ -251,6 +262,11 @@ export default defineComponent({
showEmpty(): boolean {
return !this.loading && this.empty;
},
/** Whether to allow swipe refresh */
allowSwipe(): boolean {
return !this.loading && this.currentScroll === 0;
},
},
methods: {
@ -262,7 +278,7 @@ export default defineComponent({
// Do a soft refresh if the query changes
else if (JSON.stringify(from.query) !== JSON.stringify(to.query)) {
await this.softRefreshInternal(true);
await this.softRefreshSync();
}
// Check if viewer is supposed to be open
@ -356,23 +372,38 @@ export default defineComponent({
* Debouncing is necessary due to a large number of calls, e.g.
* when changing the configuration
*/
async softRefresh() {
this.softRefreshInternal(false);
softRefresh() {
this._softRefreshInternal(false);
},
/** Fetch and re-process days (sync can be awaited) */
async softRefreshSync() {
await this._softRefreshInternal(true);
},
/**
* Fetch and re-process days (can be awaited).
* Fetch and re-process days (can be awaited if sync).
* Do not pass this function as a callback directly.
*/
async softRefreshInternal(sync: boolean) {
async _softRefreshInternal(sync: boolean) {
this.refs.selectionManager.clear();
this.fetchDayQueue = []; // reset queue
// Fetch days and reset loading
this.updateLoading(1);
const doFetch = async () => {
try {
await this.fetchDays(true);
} finally {
this.updateLoading(-1);
}
};
// Fetch days
if (sync) {
await this.fetchDays(true);
doFetch();
} else {
utils.setRenewingTimeout(this, '_softRefreshInternalTimer', () => this.fetchDays(true), 30);
utils.setRenewingTimeout(this, '_softRefreshInternalTimer', doFetch, 30);
}
},
@ -384,7 +415,7 @@ export default defineComponent({
/** Recompute static sizes of containers */
recomputeSizes() {
// Size of outer container
const e = this.refs.container!;
const e = this.refs.container!.$el;
const height = e.clientHeight;
const width = e.clientWidth;
this.containerSize = [width, height];

View File

@ -30,7 +30,8 @@ export default defineComponent({
// https://codepen.io/nzbin/pen/GGrXbp
.loading-icon {
z-index: 100000;
z-index: 100000; // above everything
pointer-events: none;
.higher {
position: relative;

View File

@ -39,6 +39,13 @@
</div>
<EditLocation ref="editLocation" :photos="photos" :disabled="processing" />
</div>
<div v-if="sections.includes(5)">
<div class="title-text">
{{ t('memories', 'Orientation (EXIF)') }}
</div>
<EditOrientation ref="editOrientation" :photos="photos" :disabled="processing" />
</div>
</div>
<div v-if="processing" class="progressbar">
@ -50,8 +57,6 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { showWarning } from '@nextcloud/dialogs';
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
const NcTextField = () => import('@nextcloud/vue/dist/Components/NcTextField');
const NcProgressBar = () => import('@nextcloud/vue/dist/Components/NcProgressBar');
@ -65,8 +70,9 @@ import EditDate from './EditDate.vue';
import EditTags from './EditTags.vue';
import EditExif from './EditExif.vue';
import EditLocation from './EditLocation.vue';
import EditOrientation from './EditOrientation.vue';
import { showError } from '@nextcloud/dialogs';
import { showWarning, showError } from '@nextcloud/dialogs';
import axios from '@nextcloud/axios';
import * as dav from '@services/dav';
@ -86,6 +92,7 @@ export default defineComponent({
EditTags,
EditExif,
EditLocation,
EditOrientation,
},
mixins: [UserConfig, ModalMixin],
@ -105,6 +112,7 @@ export default defineComponent({
editTags?: InstanceType<typeof EditTags>;
editExif?: InstanceType<typeof EditExif>;
editLocation?: InstanceType<typeof EditLocation>;
editOrientation?: InstanceType<typeof EditOrientation>;
};
},
},
@ -127,12 +135,26 @@ export default defineComponent({
// Filter out forbidden MIME types
photos = photos.filter((p) => {
if (this.c.FORBIDDEN_EDIT_MIMES.includes(p.mimetype ?? String())) {
showWarning(
this.t('memories', 'Cannot edit {name} of type {type}', { name: p.basename!, type: p.mimetype! }),
);
showError(this.t('memories', 'Cannot edit {name} of type {type}', { name: p.basename!, type: p.mimetype! }));
return false;
}
// Extra filters if orientation is in the sections
if (sections.includes(5)) {
// Videos might work but we don't want to risk it
if (p.mimetype?.startsWith('video/')) {
showError(this.t('memories', 'Cannot edit rotation on videos ({name})', { name: p.basename! }));
return false;
}
// Live photos cannot be edited because the orientation of the video
// will remain the same and look wrong.
if (p.liveid) {
showError(this.t('memories', 'Cannot edit rotation on Live Photos ({name})', { name: p.basename! }));
return false;
}
}
return true;
});
@ -225,6 +247,12 @@ export default defineComponent({
raw.CreateDate = date;
}
// Orientation
const orientation = this.refs.editOrientation?.result?.(p);
if (orientation !== null) {
raw.Orientation = orientation;
}
exifs.set(p.fileid, raw);
}
@ -266,8 +294,20 @@ export default defineComponent({
// Update EXIF if required
const raw = exifs.get(fileid) ?? {};
if (Object.keys(raw).length > 0) {
await axios.patch<null>(API.IMAGE_SETEXIF(fileid), { raw });
const info = await axios.patch<IImageInfo>(API.IMAGE_SETEXIF(fileid), { raw });
dirty = true;
// Update image size
p.h = info.data?.h ?? p.h;
p.w = info.data?.w ?? p.w;
// If orientation was updated we need to change
// the ETag so that the preview is updated.
// Deliberately don't change the tag otherwise,
// so there's no need to re-download the image.
if (raw.Orientation) {
p.etag = info.data?.etag ?? p.etag;
}
}
// Update tags if required

View File

@ -0,0 +1,258 @@
<template>
<div class="edit-orientation" v-if="samples.length">
{{
t(
'memories',
'This feature rotates images losslessly by updating the EXIF metadata. This approach is known to sometimes not work correctly on certain image types such as HEIC. Make sure you do a test run before using it on multiple images.',
)
}}
<div class="samples">
<XImg v-for="src of samples" class="sample" :key="src" :src="src" :style="{ transform }" />
<div class="sample more" v-if="photos.length > samples.length">
<span>+{{ photos.length - samples.length }}</span>
</div>
</div>
<NcActions :inline="3" class="actions">
<NcActionButton :aria-label="t('memories', 'Rotate Left')" :disabled="disabled" @click="doleft">
<template #icon> <RotateLeftIcon :size="22" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Rotate Right')" :disabled="disabled" @click="doright">
<template #icon> <RotateRightIcon :size="22" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Flip')" :disabled="disabled" @click="doflip">
<template #icon> <FlipHorizontalIcon :size="22" /> </template>
</NcActionButton>
</NcActions>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as utils from '@services/utils';
import NcActions from '@nextcloud/vue/dist/Components/NcActions';
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
import RotateRightIcon from 'vue-material-design-icons/RotateRight.vue';
import FlipHorizontalIcon from 'vue-material-design-icons/FlipHorizontal.vue';
import type { IPhoto } from '@typings';
const NORMAL = [1, 6, 3, 8];
const FLIPPED = [2, 7, 4, 5];
export default defineComponent({
components: {
NcActions,
NcActionButton,
RotateLeftIcon,
RotateRightIcon,
FlipHorizontalIcon,
},
props: {
photos: {
type: Array<IPhoto>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: {
save: () => true,
},
data: () => ({
/** Current state relative to 1 */
state: 1,
/** Full (360) rotations for CSS transition */
spins: 0,
}),
computed: {
samples() {
return this.photos.slice(0, 8).map((photo) => utils.getPreviewUrl({ photo, size: 512 }));
},
transform() {
const f = this.isflip(this.state) ? -1 : 1;
const d = this.spins;
return `${this.transform1} rotate(${d * 360 * f}deg)`;
},
transform1() {
/**
* 1 = Horizontal (normal)
* 2 = Mirror horizontal
* 3 = Rotate 180
* 4 = Mirror vertical
* 5 = Mirror horizontal and rotate 270 CW
* 6 = Rotate 90 CW
* 7 = Mirror horizontal and rotate 90 CW
* 8 = Rotate 270 CW
*/
if (this.state < 1 || this.state > 8) {
console.error('Invalid orientation state', this.state);
return null;
}
switch (this.state) {
case 1:
return 'rotate(0deg)';
case 2:
return 'scaleX(-1)';
case 3:
return 'rotate(180deg)';
case 4:
return 'scaleX(-1) rotate(-180deg)';
case 5:
return 'scaleX(-1) rotate(-270deg)';
case 6:
return 'rotate(90deg)';
case 7:
return 'scaleX(-1) rotate(-90deg)';
case 8:
return 'rotate(270deg)';
}
},
},
methods: {
/**
* Get target orientation state for a photo.
* If no change is needed, return null.
*/
result(photo: IPhoto): number | null {
const exif = photo.imageInfo?.exif;
if (!exif) return null;
let state = Number(exif.Orientation) || 1;
const oldState = state;
// Check if state is valid
if (state < 1 || state > 8) {
state = 1;
}
// Flip state if needed
if (this.isflip(this.state)) {
state = this.flip(state);
}
// Rotate state by index difference
const cindex = this.list(this.state).indexOf(this.state);
const list = this.list(state);
const sindex = list.indexOf(state);
state = list[(cindex + sindex) % list.length];
// No change
if ((!exif.Orientation && state === 1) || state === oldState) {
return null;
}
return state;
},
doleft() {
const list = this.list(this.state);
let index = list.indexOf(this.state) - 1;
if (index < 0) {
this.spins--;
index = list.length - 1;
}
this.state = list[index];
},
doright() {
const list = this.list(this.state);
let index = list.indexOf(this.state) + 1;
if (index === list.length) {
this.spins++;
index = 0;
}
this.state = list[index];
},
doflip() {
this.state = this.flip(this.state);
},
/** Flip a state in-place */
flip(state: number) {
if (this.isflip(state)) {
let i = FLIPPED.indexOf(state);
if (i === 1) i = 3;
else if (i === 3) i = 1;
return NORMAL[i];
} else {
let i = NORMAL.indexOf(state);
if (i === 1) i = 3;
else if (i === 3) i = 1;
return FLIPPED[i];
}
},
/** Check if a state is flipped */
isflip(state: number) {
return FLIPPED.includes(state);
},
/** Get rotation list for this state (flipped / regular) */
list(state: number) {
return this.isflip(state) ? FLIPPED : NORMAL;
},
},
});
</script>
<style scoped lang="scss">
.edit-orientation {
margin: 4px 0;
.samples {
display: grid;
grid-gap: 5px;
grid-template-columns: repeat(auto-fit, 80px);
justify-content: center;
margin: 6px 0;
margin-top: 10px;
.sample {
border-radius: 10px;
object-fit: cover;
width: 100%;
aspect-ratio: 1 / 1;
transition: transform 0.2s ease-in-out;
&:first-child {
grid-column: span 2;
grid-row: span 2;
}
}
.more {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-background-dark);
> span {
font-size: 1.3em;
font-weight: 500;
transform: translate(-3px, -3px);
}
}
}
.actions {
justify-content: center;
}
}
</style>

View File

@ -22,148 +22,15 @@
<div class="top-bar" v-if="photoswipe" :class="{ showControls }">
<NcActions :inline="numInlineActions" container=".memories_viewer .pswp">
<NcActionButton
v-if="canShare"
:aria-label="t('memories', 'Share')"
@click="shareCurrent"
:close-after-click="true"
v-for="action of actions"
:key="action.id"
:aria-label="action.name"
close-after-click
@click="action.callback()"
>
{{ t('memories', 'Share') }}
<template #icon> <ShareIcon :size="24" /> </template>
</NcActionButton>
<NcActionButton
v-if="!routeIsAlbums && canDelete"
:aria-label="t('memories', 'Delete')"
@click="deleteCurrent"
:close-after-click="true"
>
{{ t('memories', 'Delete') }}
<template #icon> <DeleteIcon :size="24" /> </template>
</NcActionButton>
<NcActionButton
v-if="routeIsAlbums"
:aria-label="t('memories', 'Remove from album')"
@click="deleteCurrent"
:close-after-click="true"
>
{{ t('memories', 'Remove from album') }}
<template #icon> <AlbumRemoveIcon :size="24" /> </template>
</NcActionButton>
<NcActionButton
v-if="isLivePhoto"
:aria-label="t('memories', 'Play Live Photo')"
@click="playLivePhoto"
:close-after-click="true"
>
{{ t('memories', 'Play Live Photo') }}
{{ action.name }}
<template #icon>
<LivePhotoIcon :size="24" :playing="liveState.playing" :spin="liveState.waiting" />
</template>
</NcActionButton>
<NcActionButton
v-if="!routeIsPublic && !isLocal"
:aria-label="t('memories', 'Favorite')"
@click="favoriteCurrent"
:close-after-click="true"
>
{{ t('memories', 'Favorite') }}
<template #icon>
<StarIcon v-if="isFavorite()" :size="24" />
<StarOutlineIcon v-else :size="24" />
</template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Info')" @click="toggleSidebar" :close-after-click="true">
{{ t('memories', 'Info') }}
<template #icon>
<InfoIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
v-if="canEdit && !isVideo"
:aria-label="t('memories', 'Edit')"
@click="openEditor"
:close-after-click="true"
>
{{ t('memories', 'Edit') }}
<template #icon>
<TuneIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Download')"
@click="downloadCurrent"
:close-after-click="true"
v-if="!initstate.noDownload && !isLocal"
>
{{ t('memories', 'Download') }}
<template #icon>
<DownloadIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
v-if="!initstate.noDownload && currentPhoto?.liveid"
:aria-label="t('memories', 'Download Video')"
@click="downloadCurrentLiveVideo"
:close-after-click="true"
>
{{ t('memories', 'Download Video') }}
<template #icon>
<DownloadIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
v-for="raw of stackedRaw"
:aria-label="t('memories', 'Download {ext}', { ext: raw.extension })"
@click="downloadByFileId(raw.fileid)"
:close-after-click="true"
:key="raw.fileid"
>
{{ t('memories', 'Download {ext}', { ext: raw.extension }) }}
<template #icon>
<DownloadIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
v-if="!routeIsPublic && !routeIsAlbums && !isLocal"
:aria-label="t('memories', 'View in folder')"
@click="viewInFolder"
:close-after-click="true"
>
{{ t('memories', 'View in folder') }}
<template #icon>
<OpenInNewIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Slideshow')"
v-if="globalCount > 1"
@click="startSlideshow"
:close-after-click="true"
>
{{ t('memories', 'Slideshow') }}
<template #icon>
<SlideshowIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Edit metadata')"
v-if="canEdit"
@click="editMetadata"
:close-after-click="true"
>
{{ t('memories', 'Edit metadata') }}
<template #icon>
<EditFileIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Add to album')"
v-if="config.albums_enabled && !isLocal && !routeIsPublic && canShare && currentPhoto?.imageInfo?.filename"
@click="updateAlbums"
:close-after-click="true"
>
{{ t('memories', 'Add to album') }}
<template #icon>
<AlbumIcon :size="24" />
<component :is="action.icon" :size="24" v-bind="action.iconArgs ?? {}" />
</template>
</NcActionButton>
</NcActions>
@ -221,6 +88,22 @@ import SlideshowIcon from 'vue-material-design-icons/PlayBox.vue';
import EditFileIcon from 'vue-material-design-icons/FileEdit.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
type IViewerAction = {
/** Identifier (optional) */
id: string;
/** Display text */
name: string;
/** Icon component */
icon: any;
/** Props on icon component */
iconArgs?: any;
/** Action to perform */
callback: () => void;
/** Condition to check for including */
if: boolean;
};
const SLIDESHOW_MS = 5000;
const SIDEBAR_DEBOUNCE_MS = 350;
@ -234,19 +117,6 @@ export default defineComponent({
NcActions,
NcActionButton,
ImageEditor,
LivePhotoIcon,
ShareIcon,
DeleteIcon,
StarIcon,
StarOutlineIcon,
DownloadIcon,
InfoIcon,
OpenInNewIcon,
TuneIcon,
SlideshowIcon,
EditFileIcon,
AlbumRemoveIcon,
AlbumIcon,
},
mixins: [UserConfig],
@ -358,6 +228,126 @@ export default defineComponent({
return this.list[idx];
},
/** Get all actions to show */
actions(): IViewerAction[] {
return [
{
id: 'share',
name: this.t('memories', 'Share'),
icon: ShareIcon,
callback: this.shareCurrent,
if: this.canShare,
},
{
id: 'delete',
name: this.t('memories', 'Delete'),
icon: DeleteIcon,
callback: this.deleteCurrent,
if: !this.routeIsAlbums && this.canDelete,
},
{
id: 'remove-from-album',
name: this.t('memories', 'Remove from album'),
icon: AlbumRemoveIcon,
callback: this.deleteCurrent,
if: this.routeIsAlbums,
},
{
id: 'play-live-photo',
name: this.t('memories', 'Play Live Photo'),
icon: LivePhotoIcon,
iconArgs: {
playing: this.liveState.playing,
spin: this.liveState.waiting,
},
callback: this.playLivePhoto,
if: this.isLivePhoto,
},
{
id: 'favorite',
name: this.t('memories', 'Favorite'),
icon: this.isFavorite ? StarIcon : StarOutlineIcon,
callback: this.favoriteCurrent,
if: !this.routeIsPublic && !this.isLocal,
},
{
id: 'info',
name: this.t('memories', 'Info'),
icon: InfoIcon,
callback: this.toggleSidebar,
if: true,
},
{
id: 'edit',
name: this.t('memories', 'Edit'),
icon: TuneIcon,
callback: this.openEditor,
if: this.canEdit && !this.isVideo,
},
{
id: 'download',
name: this.t('memories', 'Download'),
icon: DownloadIcon,
callback: this.downloadCurrent,
if: !this.initstate.noDownload && !this.isLocal,
},
{
id: 'download-video',
name: this.t('memories', 'Download Video'),
icon: DownloadIcon,
callback: this.downloadCurrentLiveVideo,
if: !this.initstate.noDownload && !!this.currentPhoto?.liveid,
},
...this.stackedRaw.map((raw) => ({
id: `download-raw-${raw.fileid}`,
name: this.t('memories', 'Download {ext}', { ext: raw.extension }),
icon: DownloadIcon,
callback: () => this.downloadByFileId(raw.fileid),
if: true,
})),
{
id: 'view-in-folder',
name: this.t('memories', 'View in folder'),
icon: OpenInNewIcon,
callback: this.viewInFolder,
if: !this.routeIsPublic && !this.routeIsAlbums && !this.isLocal,
},
{
id: 'slideshow',
name: this.t('memories', 'Slideshow'),
icon: SlideshowIcon,
callback: this.startSlideshow,
if: this.globalCount > 1,
},
{
id: 'edit-metadata',
name: this.t('memories', 'Edit metadata'),
icon: EditFileIcon,
callback: () => this.editMetadata(),
if: this.canEdit,
},
{
id: 'rotate-flip',
name: this.t('memories', 'Rotate / Flip'),
icon: RotateLeftIcon,
callback: () => this.editMetadata([5]),
if: this.canEdit && !this.isVideo,
},
{
id: 'add-to-album',
name: this.t('memories', 'Add to album'),
icon: AlbumIcon,
callback: this.updateAlbums,
if:
this.config.albums_enabled &&
!this.isLocal &&
!this.routeIsPublic &&
this.canShare &&
!!this.currentPhoto?.imageInfo?.filename,
},
].filter((action) => action.if);
},
/** Is the current slide a video */
isVideo(): boolean {
return Boolean((this.currentPhoto?.flag ?? 0) & this.c.FLAG_IS_VIDEO);
@ -373,6 +363,13 @@ export default defineComponent({
return utils.isLocalPhoto(this.currentPhoto!);
},
/** Is the current photo a favorite */
isFavorite() {
const p = this.currentPhoto;
if (!p) return false;
return Boolean(p.flag & this.c.FLAG_IS_FAVORITE);
},
/** Show bottom bar info such as date taken */
showBottomBar(): boolean {
return !this.isVideo && this.fullyOpened && Boolean(this.currentPhoto?.imageInfo);
@ -1049,17 +1046,10 @@ export default defineComponent({
this.psLivePhoto?.play(this.photoswipe!.currSlide!.content as PsContent);
},
/** Is the current photo a favorite */
isFavorite() {
const p = this.currentPhoto;
if (!p) return false;
return Boolean(p.flag & this.c.FLAG_IS_FAVORITE);
},
/** Favorite the current photo */
async favoriteCurrent() {
const photo = this.currentPhoto!;
const val = !this.isFavorite();
const val = !this.isFavorite;
try {
this.updateLoading(1);
for await (const p of dav.favoritePhotos([photo], val)) {
@ -1256,8 +1246,8 @@ export default defineComponent({
/**
* Edit metadata for current photo
*/
editMetadata() {
_m.modals.editMetadata([this.currentPhoto!]);
editMetadata(sections?: number[]) {
_m.modals.editMetadata([this.currentPhoto!], sections);
},
/**

View File

@ -117,7 +117,6 @@ export const fragment = {
// If the fragment is already in the list, we can't touch it.
if (list.find((f) => f.type === frag.type)) {
console.debug('Fragment already in route', frag.type);
return;
}