Merge branch 'master' of https://github.com/pulsejet/memories
commit
7b3119c133
|
@ -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))
|
||||
|
|
|
@ -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)." : "缩略图生成可能不适用于某些格式(HEIC,TIFF)。",
|
||||
"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)",
|
||||
|
|
|
@ -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)." : "缩略图生成可能不适用于某些格式(HEIC,TIFF)。",
|
||||
"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)",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue